@aiwerk/mcp-bridge 1.9.0 → 2.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.
@@ -1,4 +1,4 @@
1
- export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, warnIfNonTlsRemoteUrl } from "./transport-base.js";
1
+ export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveServerHeaders, warnIfNonTlsRemoteUrl } from "./transport-base.js";
2
2
  export { StdioTransport } from "./transport-stdio.js";
3
3
  export { SseTransport } from "./transport-sse.js";
4
4
  export { StreamableHttpTransport } from "./transport-streamable-http.js";
@@ -11,7 +11,7 @@ export type { ToolResolutionResult, ToolResolutionCandidate } from "./tool-resol
11
11
  export { convertJsonSchemaToTypeBox, createToolParameters, setTypeBoxLoader, setSchemaLogger } from "./schema-convert.js";
12
12
  export { initializeProtocol, fetchToolsList, PACKAGE_VERSION } from "./protocol.js";
13
13
  export { loadConfig, parseEnvFile, initConfigDir, getConfigDir } from "./config.js";
14
- export type { Logger, McpServerConfig, McpClientConfig, McpTool, McpRequest, McpCallRequest, McpResponse, JsonRpcMessage, McpTransport, McpServerConnection, BridgeConfig, } from "./types.js";
14
+ export type { Logger, McpServerConfig, McpClientConfig, HttpAuthConfig, RetryConfig, McpTool, McpRequest, McpCallRequest, McpResponse, JsonRpcMessage, McpTransport, McpServerConnection, BridgeConfig, } from "./types.js";
15
15
  export { nextRequestId } from "./types.js";
16
16
  export { pickRegisteredToolName } from "./tool-naming.js";
17
17
  export { StandaloneServer } from "./standalone-server.js";
package/dist/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Core exports for @aiwerk/mcp-bridge
2
2
  // Transport classes
3
- export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, warnIfNonTlsRemoteUrl } from "./transport-base.js";
3
+ export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveServerHeaders, warnIfNonTlsRemoteUrl } from "./transport-base.js";
4
4
  export { StdioTransport } from "./transport-stdio.js";
5
5
  export { SseTransport } from "./transport-sse.js";
6
6
  export { StreamableHttpTransport } from "./transport-streamable-http.js";
@@ -37,6 +37,7 @@ export type RouterDispatchResponse = {
37
37
  action: "call";
38
38
  tool: string;
39
39
  result: any;
40
+ retries?: number;
40
41
  } | {
41
42
  server: string;
42
43
  action: "schema";
@@ -122,7 +123,11 @@ export declare class McpRouter {
122
123
  inputSchema: any;
123
124
  }>;
124
125
  private getPromotionStats;
126
+ private getRetryPolicy;
127
+ private classifyTransientError;
128
+ private callToolWithRetry;
125
129
  disconnectAll(): Promise<void>;
130
+ shutdown(timeoutMs?: number): Promise<void>;
126
131
  private ensureConnected;
127
132
  private enforceMaxConcurrent;
128
133
  private disconnectServer;
@@ -12,6 +12,7 @@ import { ToolResolver } from "./tool-resolution.js";
12
12
  const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
13
13
  const DEFAULT_MAX_CONCURRENT = 5;
14
14
  const DEFAULT_MAX_BATCH_SIZE = 10;
15
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000;
15
16
  export class McpRouter {
16
17
  servers;
17
18
  clientConfig;
@@ -227,14 +228,8 @@ export class McpRouter {
227
228
  }
228
229
  }
229
230
  this.markUsed(server);
230
- const response = await state.transport.sendRequest({
231
- jsonrpc: "2.0",
232
- method: "tools/call",
233
- params: {
234
- name: tool,
235
- arguments: params ?? {}
236
- }
237
- });
231
+ const callOutcome = await this.callToolWithRetry(server, tool, params ?? {}, state.transport);
232
+ const response = callOutcome.response;
238
233
  if (response.error) {
239
234
  return this.error("mcp_error", response.error.message, undefined, response.error.code);
240
235
  }
@@ -248,7 +243,13 @@ export class McpRouter {
248
243
  if (this.resultCache && cacheKey) {
249
244
  this.resultCache.set(cacheKey, result);
250
245
  }
251
- return { server, action: "call", tool, result };
246
+ return {
247
+ server,
248
+ action: "call",
249
+ tool,
250
+ result,
251
+ ...(callOutcome.retries > 0 ? { retries: callOutcome.retries } : {})
252
+ };
252
253
  }
253
254
  catch (error) {
254
255
  return this.error("mcp_error", error instanceof Error ? error.message : String(error));
@@ -390,11 +391,103 @@ export class McpRouter {
390
391
  }));
391
392
  return { action: "promotions", promoted, stats };
392
393
  }
394
+ getRetryPolicy(server) {
395
+ const globalRetry = this.clientConfig.retry ?? {};
396
+ const serverRetry = this.servers[server].retry ?? {};
397
+ const maxAttemptsRaw = serverRetry.maxAttempts ?? globalRetry.maxAttempts ?? 1;
398
+ const delayMsRaw = serverRetry.delayMs ?? globalRetry.delayMs ?? 1000;
399
+ const backoffMultiplierRaw = serverRetry.backoffMultiplier ?? globalRetry.backoffMultiplier ?? 2;
400
+ const retryOn = serverRetry.retryOn ?? globalRetry.retryOn ?? ["timeout", "connection_error"];
401
+ return {
402
+ maxAttempts: Math.max(1, Math.floor(maxAttemptsRaw)),
403
+ delayMs: Math.max(0, Math.floor(delayMsRaw)),
404
+ backoffMultiplier: Math.max(1, backoffMultiplierRaw),
405
+ retryOn: new Set(retryOn)
406
+ };
407
+ }
408
+ classifyTransientError(error) {
409
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
410
+ if (message.includes("timeout") ||
411
+ message.includes("timed out") ||
412
+ message.includes("abort")) {
413
+ return "timeout";
414
+ }
415
+ if (message.includes("connection") ||
416
+ message.includes("econnreset") ||
417
+ message.includes("socket hang up") ||
418
+ message.includes("network") ||
419
+ message.includes("fetch failed") ||
420
+ message.includes("econnrefused") ||
421
+ message.includes("enotfound")) {
422
+ return "connection_error";
423
+ }
424
+ return null;
425
+ }
426
+ async callToolWithRetry(server, tool, args, transport) {
427
+ const retryPolicy = this.getRetryPolicy(server);
428
+ let retries = 0;
429
+ let lastError;
430
+ for (let attempt = 0; attempt < retryPolicy.maxAttempts; attempt++) {
431
+ try {
432
+ const response = await transport.sendRequest({
433
+ jsonrpc: "2.0",
434
+ method: "tools/call",
435
+ params: {
436
+ name: tool,
437
+ arguments: args
438
+ }
439
+ });
440
+ return { response, retries };
441
+ }
442
+ catch (error) {
443
+ lastError = error;
444
+ const category = this.classifyTransientError(error);
445
+ const shouldRetry = category !== null &&
446
+ retryPolicy.retryOn.has(category) &&
447
+ attempt < retryPolicy.maxAttempts - 1;
448
+ if (!shouldRetry) {
449
+ throw error;
450
+ }
451
+ retries += 1;
452
+ const delay = retryPolicy.delayMs * Math.pow(retryPolicy.backoffMultiplier, attempt);
453
+ if (delay > 0) {
454
+ await new Promise((resolve) => setTimeout(resolve, delay));
455
+ }
456
+ }
457
+ }
458
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
459
+ }
393
460
  async disconnectAll() {
394
461
  for (const serverName of Object.keys(this.servers)) {
395
462
  await this.disconnectServer(serverName);
396
463
  }
397
464
  }
465
+ async shutdown(timeoutMs = this.clientConfig.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS) {
466
+ const effectiveTimeout = Math.max(0, timeoutMs);
467
+ for (const [serverName, state] of this.states) {
468
+ if (state.idleTimer) {
469
+ clearTimeout(state.idleTimer);
470
+ state.idleTimer = null;
471
+ }
472
+ try {
473
+ if (state.transport.shutdown) {
474
+ await state.transport.shutdown(effectiveTimeout);
475
+ }
476
+ else {
477
+ await state.transport.disconnect();
478
+ }
479
+ }
480
+ catch (error) {
481
+ this.logger.warn(`[mcp-bridge] Router shutdown: failed to close ${serverName}:`, error);
482
+ }
483
+ }
484
+ this.states.clear();
485
+ this.toolResolver.clear();
486
+ if (this.intentRouter) {
487
+ this.intentRouter.clearIndex();
488
+ }
489
+ this.resultCache?.invalidate();
490
+ }
398
491
  async ensureConnected(server) {
399
492
  let state = this.states.get(server);
400
493
  if (!state) {
@@ -413,7 +413,7 @@ export class StandaloneServer {
413
413
  async shutdown() {
414
414
  this.logger.info("[mcp-bridge] Shutting down...");
415
415
  if (this.router) {
416
- await this.router.disconnectAll();
416
+ await this.router.shutdown(this.config.shutdownTimeoutMs);
417
417
  }
418
418
  for (const [name, conn] of this.directConnections) {
419
419
  try {
@@ -26,6 +26,7 @@ export declare class ToolResolver {
26
26
  resolve(toolName: string, params?: Record<string, unknown>, serverHint?: string): ToolResolutionResult;
27
27
  recordCall(server: string, tool: string): void;
28
28
  getKnownToolNames(): string[];
29
+ clear(): void;
29
30
  private scoreCandidate;
30
31
  private wasUsedRecently;
31
32
  private computeParamMatch;
@@ -99,6 +99,11 @@ export class ToolResolver {
99
99
  getKnownToolNames() {
100
100
  return [...this.toolsByName.keys()];
101
101
  }
102
+ clear() {
103
+ this.toolsByName.clear();
104
+ this.toolNamesByServer.clear();
105
+ this.recentCalls.length = 0;
106
+ }
102
107
  scoreCandidate(server, inputSchema, params) {
103
108
  const base = this.basePriority.get(server) ?? BASE_PRIORITY_MIN;
104
109
  const recency = this.wasUsedRecently(server) ? RECENCY_BOOST : 0;
@@ -70,6 +70,14 @@ export declare function resolveEnvRecord(record: Record<string, string>, context
70
70
  * @param extraEnv - Additional env vars to check before process.env
71
71
  */
72
72
  export declare function resolveArgs(args: string[], extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): string[];
73
+ /**
74
+ * Resolve auth config into HTTP headers.
75
+ */
76
+ export declare function resolveAuthHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
77
+ /**
78
+ * Resolve server headers and merge auth headers (auth takes precedence).
79
+ */
80
+ export declare function resolveServerHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
73
81
  /**
74
82
  * Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
75
83
  */
@@ -159,6 +159,26 @@ export function resolveEnvRecord(record, contextPrefix, extraEnv, envFallback) {
159
159
  export function resolveArgs(args, extraEnv, envFallback) {
160
160
  return args.map(arg => resolveEnvVars(arg, `arg "${arg}"`, extraEnv, envFallback));
161
161
  }
162
+ /**
163
+ * Resolve auth config into HTTP headers.
164
+ */
165
+ export function resolveAuthHeaders(config, extraEnv, envFallback) {
166
+ if (!config.auth)
167
+ return {};
168
+ if (config.auth.type === "bearer") {
169
+ const token = resolveEnvVars(config.auth.token, "auth token", extraEnv, envFallback);
170
+ return { Authorization: `Bearer ${token}` };
171
+ }
172
+ return resolveEnvRecord(config.auth.headers, "auth header", extraEnv, envFallback);
173
+ }
174
+ /**
175
+ * Resolve server headers and merge auth headers (auth takes precedence).
176
+ */
177
+ export function resolveServerHeaders(config, extraEnv, envFallback) {
178
+ const base = resolveEnvRecord(config.headers || {}, "header", extraEnv, envFallback);
179
+ const auth = resolveAuthHeaders(config, extraEnv, envFallback);
180
+ return { ...base, ...auth };
181
+ }
162
182
  /**
163
183
  * Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
164
184
  */
@@ -4,6 +4,7 @@ export declare class SseTransport extends BaseTransport {
4
4
  private endpointUrl;
5
5
  private sseAbortController;
6
6
  private resolvedHeaders;
7
+ private pendingRequestControllers;
7
8
  protected get transportName(): string;
8
9
  connect(): Promise<void>;
9
10
  private _onEndpointReceived;
@@ -13,4 +14,5 @@ export declare class SseTransport extends BaseTransport {
13
14
  sendRequest(request: McpRequest): Promise<McpResponse>;
14
15
  private isSameOrigin;
15
16
  disconnect(): Promise<void>;
17
+ shutdown(): Promise<void>;
16
18
  }
@@ -1,9 +1,10 @@
1
1
  import { nextRequestId } from "./types.js";
2
- import { BaseTransport, resolveEnvRecord, warnIfNonTlsRemoteUrl } from "./transport-base.js";
2
+ import { BaseTransport, resolveServerHeaders, warnIfNonTlsRemoteUrl } from "./transport-base.js";
3
3
  export class SseTransport extends BaseTransport {
4
4
  endpointUrl = null;
5
5
  sseAbortController = null;
6
6
  resolvedHeaders = null;
7
+ pendingRequestControllers = new Map();
7
8
  get transportName() { return "SSE"; }
8
9
  async connect() {
9
10
  if (!this.config.url) {
@@ -11,7 +12,7 @@ export class SseTransport extends BaseTransport {
11
12
  }
12
13
  warnIfNonTlsRemoteUrl(this.config.url, this.logger);
13
14
  // Resolve headers once and cache for all subsequent requests
14
- this.resolvedHeaders = resolveEnvRecord(this.config.headers || {}, "header");
15
+ this.resolvedHeaders = resolveServerHeaders(this.config);
15
16
  if (this.sseAbortController) {
16
17
  this.sseAbortController.abort();
17
18
  }
@@ -36,7 +37,7 @@ export class SseTransport extends BaseTransport {
36
37
  async startEventStream() {
37
38
  if (!this.config.url)
38
39
  return;
39
- const base = this.resolvedHeaders ?? resolveEnvRecord(this.config.headers || {}, "header");
40
+ const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
40
41
  const headers = { ...base, "Accept": "text/event-stream" };
41
42
  try {
42
43
  const response = await fetch(this.config.url, {
@@ -130,7 +131,7 @@ export class SseTransport extends BaseTransport {
130
131
  if (!this.connected || !this.endpointUrl) {
131
132
  throw new Error("SSE transport not connected or no endpoint URL");
132
133
  }
133
- const base = this.resolvedHeaders ?? resolveEnvRecord(this.config.headers || {}, "header");
134
+ const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
134
135
  const headers = { ...base, "Content-Type": "application/json" };
135
136
  const response = await fetch(this.endpointUrl, {
136
137
  method: "POST",
@@ -150,21 +151,27 @@ export class SseTransport extends BaseTransport {
150
151
  return new Promise((resolve, reject) => {
151
152
  const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
152
153
  const timeout = setTimeout(() => {
154
+ this.pendingRequestControllers.get(id)?.abort();
155
+ this.pendingRequestControllers.delete(id);
153
156
  this.pendingRequests.delete(id);
154
157
  reject(new Error(`Request timeout after ${requestTimeout}ms`));
155
158
  }, requestTimeout);
156
159
  this.pendingRequests.set(id, { resolve, reject, timeout });
157
- const base = this.resolvedHeaders ?? resolveEnvRecord(this.config.headers || {}, "header");
160
+ const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
158
161
  const headers = { ...base, "Content-Type": "application/json" };
162
+ const abortController = new AbortController();
163
+ this.pendingRequestControllers.set(id, abortController);
159
164
  // The response arrives via the SSE stream (handleMessage), not from this fetch.
160
165
  // The fetch only confirms the server accepted the request (HTTP 200).
161
166
  // If the fetch fails, we reject immediately; otherwise we wait for the SSE stream.
162
167
  fetch(this.endpointUrl, {
163
168
  method: "POST",
164
169
  headers,
165
- body: JSON.stringify(requestWithId)
170
+ body: JSON.stringify(requestWithId),
171
+ signal: abortController.signal
166
172
  })
167
173
  .then((response) => {
174
+ this.pendingRequestControllers.delete(id);
168
175
  if (!response.ok) {
169
176
  clearTimeout(timeout);
170
177
  this.pendingRequests.delete(id);
@@ -172,6 +179,7 @@ export class SseTransport extends BaseTransport {
172
179
  }
173
180
  })
174
181
  .catch((error) => {
182
+ this.pendingRequestControllers.delete(id);
175
183
  clearTimeout(timeout);
176
184
  this.pendingRequests.delete(id);
177
185
  reject(error);
@@ -197,6 +205,13 @@ export class SseTransport extends BaseTransport {
197
205
  this.sseAbortController.abort();
198
206
  this.sseAbortController = null;
199
207
  }
208
+ for (const [, controller] of this.pendingRequestControllers) {
209
+ controller.abort();
210
+ }
211
+ this.pendingRequestControllers.clear();
200
212
  this.rejectAllPending("Connection closed");
201
213
  }
214
+ async shutdown() {
215
+ await this.disconnect();
216
+ }
202
217
  }
@@ -15,6 +15,7 @@ export declare class StdioTransport extends BaseTransport {
15
15
  private parseNewlineMessageFromBuffer;
16
16
  private parseLspMessageFromBuffer;
17
17
  disconnect(): Promise<void>;
18
+ shutdown(timeoutMs?: number): Promise<void>;
18
19
  isConnected(): boolean;
19
20
  private terminateProcessGracefully;
20
21
  }
@@ -264,7 +264,20 @@ export class StdioTransport extends BaseTransport {
264
264
  this.logger.debug("[mcp-bridge] Failed to send close notification during stdio disconnect");
265
265
  }
266
266
  }
267
- await this.terminateProcessGracefully(activeProcess);
267
+ await this.terminateProcessGracefully(activeProcess, this.clientConfig.shutdownTimeoutMs ?? 5000);
268
+ if (this.process === activeProcess) {
269
+ this.process = null;
270
+ }
271
+ }
272
+ this.rejectAllPending("Connection closed");
273
+ }
274
+ async shutdown(timeoutMs = this.clientConfig.shutdownTimeoutMs ?? 5000) {
275
+ this.isShuttingDown = true;
276
+ this.connected = false;
277
+ this.cleanupReconnectTimer();
278
+ const activeProcess = this.process;
279
+ if (activeProcess) {
280
+ await this.terminateProcessGracefully(activeProcess, timeoutMs);
268
281
  if (this.process === activeProcess) {
269
282
  this.process = null;
270
283
  }
@@ -274,8 +287,8 @@ export class StdioTransport extends BaseTransport {
274
287
  isConnected() {
275
288
  return this.connected && this.process !== null;
276
289
  }
277
- async terminateProcessGracefully(proc) {
278
- if (proc.exitCode !== null || proc.killed)
290
+ async terminateProcessGracefully(proc, timeoutMs) {
291
+ if (proc.exitCode !== null)
279
292
  return;
280
293
  await new Promise((resolve) => {
281
294
  let done = false;
@@ -292,21 +305,20 @@ export class StdioTransport extends BaseTransport {
292
305
  const onExit = () => finish();
293
306
  proc.once("exit", onExit);
294
307
  try {
295
- proc.kill("SIGINT");
308
+ proc.kill("SIGTERM");
296
309
  }
297
310
  catch {
298
311
  finish();
299
312
  return;
300
313
  }
301
314
  forceKillTimer = setTimeout(() => {
302
- if (proc.exitCode === null && !proc.killed) {
315
+ if (proc.exitCode === null) {
303
316
  try {
304
- proc.kill("SIGTERM");
317
+ proc.kill("SIGKILL");
305
318
  }
306
319
  catch { /* ignore */ }
307
320
  }
308
- setTimeout(finish, 200);
309
- }, 2000);
321
+ }, Math.max(0, timeoutMs));
310
322
  });
311
323
  }
312
324
  }
@@ -2,10 +2,13 @@ import { McpRequest, McpResponse } from "./types.js";
2
2
  import { BaseTransport } from "./transport-base.js";
3
3
  export declare class StreamableHttpTransport extends BaseTransport {
4
4
  private sessionId?;
5
+ private resolvedHeaders;
6
+ private pendingRequestControllers;
5
7
  protected get transportName(): string;
6
8
  connect(): Promise<void>;
7
9
  sendRequest(request: McpRequest): Promise<McpResponse>;
8
10
  sendNotification(notification: any): Promise<void>;
9
11
  private probeServer;
10
12
  disconnect(): Promise<void>;
13
+ shutdown(): Promise<void>;
11
14
  }
@@ -1,15 +1,17 @@
1
1
  import { nextRequestId } from "./types.js";
2
- import { BaseTransport, resolveEnvRecord, warnIfNonTlsRemoteUrl } from "./transport-base.js";
2
+ import { BaseTransport, resolveServerHeaders, warnIfNonTlsRemoteUrl } from "./transport-base.js";
3
3
  export class StreamableHttpTransport extends BaseTransport {
4
4
  sessionId;
5
+ resolvedHeaders = null;
6
+ pendingRequestControllers = new Map();
5
7
  get transportName() { return "streamable-http"; }
6
8
  async connect() {
7
9
  if (!this.config.url) {
8
10
  throw new Error("Streamable HTTP transport requires URL");
9
11
  }
10
12
  warnIfNonTlsRemoteUrl(this.config.url, this.logger);
11
- // Validate that all header env vars resolve (fail fast)
12
- resolveEnvRecord(this.config.headers || {}, "header");
13
+ // Validate that all header/auth env vars resolve (fail fast)
14
+ this.resolvedHeaders = resolveServerHeaders(this.config);
13
15
  await this.probeServer();
14
16
  this.connected = true;
15
17
  this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
@@ -23,25 +25,32 @@ export class StreamableHttpTransport extends BaseTransport {
23
25
  const requestWithId = { ...request, id };
24
26
  return new Promise((resolve, reject) => {
25
27
  const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
28
+ const abortController = new AbortController();
26
29
  const timeout = setTimeout(() => {
30
+ abortController.abort();
31
+ this.pendingRequestControllers.delete(id);
27
32
  this.pendingRequests.delete(id);
28
33
  reject(new Error(`Request timeout after ${requestTimeout}ms`));
29
34
  }, requestTimeout);
30
35
  this.pendingRequests.set(id, { resolve, reject, timeout });
31
- const headers = resolveEnvRecord({
36
+ this.pendingRequestControllers.set(id, abortController);
37
+ const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
38
+ const headers = {
39
+ ...base,
32
40
  "Accept": "application/json, text/event-stream",
33
- ...this.config.headers,
34
41
  "Content-Type": "application/json"
35
- }, "header");
42
+ };
36
43
  if (this.sessionId) {
37
44
  headers["mcp-session-id"] = this.sessionId;
38
45
  }
39
46
  fetch(this.config.url, {
40
47
  method: "POST",
41
48
  headers,
42
- body: JSON.stringify(requestWithId)
49
+ body: JSON.stringify(requestWithId),
50
+ signal: abortController.signal
43
51
  })
44
52
  .then(async (response) => {
53
+ this.pendingRequestControllers.delete(id);
45
54
  const responseSessionId = response.headers.get("mcp-session-id");
46
55
  if (responseSessionId) {
47
56
  this.sessionId = responseSessionId;
@@ -98,6 +107,7 @@ export class StreamableHttpTransport extends BaseTransport {
98
107
  }
99
108
  })
100
109
  .catch(error => {
110
+ this.pendingRequestControllers.delete(id);
101
111
  clearTimeout(timeout);
102
112
  this.pendingRequests.delete(id);
103
113
  if (error.name === 'TypeError' && error.message.includes('fetch')) {
@@ -112,11 +122,12 @@ export class StreamableHttpTransport extends BaseTransport {
112
122
  if (!this.connected || !this.config.url) {
113
123
  throw new Error("Streamable HTTP transport not connected");
114
124
  }
115
- const headers = resolveEnvRecord({
125
+ const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
126
+ const headers = {
127
+ ...base,
116
128
  "Accept": "application/json, text/event-stream",
117
- ...this.config.headers,
118
129
  "Content-Type": "application/json"
119
- }, "header");
130
+ };
120
131
  if (this.sessionId) {
121
132
  headers["mcp-session-id"] = this.sessionId;
122
133
  }
@@ -146,10 +157,10 @@ export class StreamableHttpTransport extends BaseTransport {
146
157
  if (!this.config.url)
147
158
  return;
148
159
  try {
149
- const optionsResponse = await fetch(this.config.url, { method: "OPTIONS" });
160
+ const headers = this.resolvedHeaders ?? resolveServerHeaders(this.config);
161
+ const optionsResponse = await fetch(this.config.url, { method: "OPTIONS", headers });
150
162
  if (optionsResponse.ok)
151
163
  return;
152
- const headers = resolveEnvRecord(this.config.headers || {}, "header");
153
164
  const headResponse = await fetch(this.config.url, { method: "HEAD", headers });
154
165
  if (!headResponse.ok) {
155
166
  this.logger.warn(`[mcp-bridge] Streamable HTTP server probe: OPTIONS ${optionsResponse.status}, HEAD ${headResponse.status} (non-blocking, connection continues)`);
@@ -162,11 +173,15 @@ export class StreamableHttpTransport extends BaseTransport {
162
173
  async disconnect() {
163
174
  this.connected = false;
164
175
  this.cleanupReconnectTimer();
176
+ for (const [, controller] of this.pendingRequestControllers) {
177
+ controller.abort();
178
+ }
179
+ this.pendingRequestControllers.clear();
165
180
  // Send DELETE request if we have a session to clean up
166
181
  if (this.sessionId && this.config.url) {
167
182
  try {
168
- const headers = resolveEnvRecord(this.config.headers || {}, "header");
169
- headers["mcp-session-id"] = this.sessionId;
183
+ const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
184
+ const headers = { ...base, "mcp-session-id": this.sessionId };
170
185
  await fetch(this.config.url, {
171
186
  method: "DELETE",
172
187
  headers
@@ -180,4 +195,7 @@ export class StreamableHttpTransport extends BaseTransport {
180
195
  }
181
196
  this.rejectAllPending("Connection closed");
182
197
  }
198
+ async shutdown() {
199
+ await this.disconnect();
200
+ }
183
201
  }
@@ -4,12 +4,26 @@ export interface Logger {
4
4
  error: (...args: unknown[]) => void;
5
5
  debug: (...args: unknown[]) => void;
6
6
  }
7
+ export type HttpAuthConfig = {
8
+ type: "bearer";
9
+ token: string;
10
+ } | {
11
+ type: "header";
12
+ headers: Record<string, string>;
13
+ };
14
+ export interface RetryConfig {
15
+ maxAttempts?: number;
16
+ delayMs?: number;
17
+ backoffMultiplier?: number;
18
+ retryOn?: Array<"timeout" | "connection_error">;
19
+ }
7
20
  export interface McpServerConfig {
8
21
  transport: "sse" | "stdio" | "streamable-http";
9
22
  /** Human-readable description for router tool description generation */
10
23
  description?: string;
11
24
  url?: string;
12
25
  headers?: Record<string, string>;
26
+ auth?: HttpAuthConfig;
13
27
  command?: string;
14
28
  args?: string[];
15
29
  env?: Record<string, string>;
@@ -20,6 +34,7 @@ export interface McpServerConfig {
20
34
  allow?: string[];
21
35
  };
22
36
  maxResultChars?: number;
37
+ retry?: RetryConfig;
23
38
  }
24
39
  export interface McpClientConfig {
25
40
  servers: Record<string, McpServerConfig>;
@@ -28,6 +43,7 @@ export interface McpClientConfig {
28
43
  reconnectIntervalMs?: number;
29
44
  connectionTimeoutMs?: number;
30
45
  requestTimeoutMs?: number;
46
+ shutdownTimeoutMs?: number;
31
47
  routerIdleTimeoutMs?: number;
32
48
  routerMaxConcurrent?: number;
33
49
  maxBatchSize?: number;
@@ -49,6 +65,7 @@ export interface McpClientConfig {
49
65
  minCalls?: number;
50
66
  decayMs?: number;
51
67
  };
68
+ retry?: RetryConfig;
52
69
  resultCache?: {
53
70
  enabled?: boolean;
54
71
  maxEntries?: number;
@@ -98,6 +115,7 @@ export interface McpResponse {
98
115
  export interface McpTransport {
99
116
  connect(): Promise<void>;
100
117
  disconnect(): Promise<void>;
118
+ shutdown?(timeoutMs?: number): Promise<void>;
101
119
  sendRequest(request: McpRequest): Promise<McpResponse>;
102
120
  sendNotification(notification: any): Promise<void>;
103
121
  isConnected(): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "1.9.0",
3
+ "version": "2.0.0",
4
4
  "description": "Standalone MCP server that multiplexes multiple MCP servers into one interface",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",