@aiwerk/mcp-bridge 1.9.0 → 2.1.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.
@@ -267,7 +267,7 @@ async function cmdServe(args, logger) {
267
267
  process.exit(1);
268
268
  }
269
269
  // HTTP modes: require auth
270
- if ((args.sse || args.http) && !config.http?.auth?.token) {
270
+ if ((args.sse || args.http) && !config.http?.auth) {
271
271
  logger.error("HTTP auth not configured. Set http.auth in config or use stdio mode.");
272
272
  process.exit(1);
273
273
  }
@@ -1,7 +1,9 @@
1
- export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, warnIfNonTlsRemoteUrl } from "./transport-base.js";
1
+ export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveAuthHeadersAsync, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, 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";
5
+ export { OAuth2TokenManager } from "./oauth2-token-manager.js";
6
+ export type { OAuth2Config } from "./oauth2-token-manager.js";
5
7
  export { McpRouter } from "./mcp-router.js";
6
8
  export type { RouterToolHint, RouterServerStatus, RouterDispatchResponse, RouterTransportRefs } from "./mcp-router.js";
7
9
  export { ResultCache, createResultCacheKey, stableStringify } from "./result-cache.js";
@@ -11,7 +13,7 @@ export type { ToolResolutionResult, ToolResolutionCandidate } from "./tool-resol
11
13
  export { convertJsonSchemaToTypeBox, createToolParameters, setTypeBoxLoader, setSchemaLogger } from "./schema-convert.js";
12
14
  export { initializeProtocol, fetchToolsList, PACKAGE_VERSION } from "./protocol.js";
13
15
  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";
16
+ export type { Logger, McpServerConfig, McpClientConfig, HttpAuthConfig, RetryConfig, McpTool, McpRequest, McpCallRequest, McpResponse, JsonRpcMessage, McpTransport, McpServerConnection, BridgeConfig, } from "./types.js";
15
17
  export { nextRequestId } from "./types.js";
16
18
  export { pickRegisteredToolName } from "./tool-naming.js";
17
19
  export { StandaloneServer } from "./standalone-server.js";
package/dist/src/index.js CHANGED
@@ -1,9 +1,10 @@
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, resolveAuthHeadersAsync, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, 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";
7
+ export { OAuth2TokenManager } from "./oauth2-token-manager.js";
7
8
  // Router
8
9
  export { McpRouter } from "./mcp-router.js";
9
10
  // Result cache
@@ -1,4 +1,5 @@
1
1
  import { McpClientConfig, McpServerConfig, McpTransport, Logger } from "./types.js";
2
+ import { OAuth2TokenManager } from "./oauth2-token-manager.js";
2
3
  type RouterErrorCode = "unknown_server" | "unknown_tool" | "connection_failed" | "mcp_error" | "invalid_params";
3
4
  interface RouterBatchResult {
4
5
  server: string;
@@ -37,6 +38,7 @@ export type RouterDispatchResponse = {
37
38
  action: "call";
38
39
  tool: string;
39
40
  result: any;
41
+ retries?: number;
40
42
  } | {
41
43
  server: string;
42
44
  action: "schema";
@@ -91,9 +93,9 @@ export type RouterDispatchResponse = {
91
93
  code?: number;
92
94
  };
93
95
  export interface RouterTransportRefs {
94
- sse: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>) => McpTransport;
96
+ sse: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager) => McpTransport;
95
97
  stdio: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>) => McpTransport;
96
- streamableHttp: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>) => McpTransport;
98
+ streamableHttp: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager) => McpTransport;
97
99
  }
98
100
  export declare class McpRouter {
99
101
  private readonly servers;
@@ -106,6 +108,7 @@ export declare class McpRouter {
106
108
  private readonly maxBatchSize;
107
109
  private readonly states;
108
110
  private readonly toolResolver;
111
+ private readonly tokenManager;
109
112
  private intentRouter;
110
113
  private promotion;
111
114
  constructor(servers: Record<string, McpServerConfig>, clientConfig: McpClientConfig, logger: Logger, transportRefs?: Partial<RouterTransportRefs>);
@@ -122,7 +125,11 @@ export declare class McpRouter {
122
125
  inputSchema: any;
123
126
  }>;
124
127
  private getPromotionStats;
128
+ private getRetryPolicy;
129
+ private classifyTransientError;
130
+ private callToolWithRetry;
125
131
  disconnectAll(): Promise<void>;
132
+ shutdown(timeoutMs?: number): Promise<void>;
126
133
  private ensureConnected;
127
134
  private enforceMaxConcurrent;
128
135
  private disconnectServer;
@@ -9,9 +9,11 @@ import { isToolAllowed, processResult } from "./security.js";
9
9
  import { AdaptivePromotion } from "./adaptive-promotion.js";
10
10
  import { ResultCache, createResultCacheKey } from "./result-cache.js";
11
11
  import { ToolResolver } from "./tool-resolution.js";
12
+ import { OAuth2TokenManager } from "./oauth2-token-manager.js";
12
13
  const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
13
14
  const DEFAULT_MAX_CONCURRENT = 5;
14
15
  const DEFAULT_MAX_BATCH_SIZE = 10;
16
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000;
15
17
  export class McpRouter {
16
18
  servers;
17
19
  clientConfig;
@@ -23,6 +25,7 @@ export class McpRouter {
23
25
  maxBatchSize;
24
26
  states = new Map();
25
27
  toolResolver;
28
+ tokenManager;
26
29
  intentRouter = null;
27
30
  promotion = null;
28
31
  constructor(servers, clientConfig, logger, transportRefs) {
@@ -45,6 +48,7 @@ export class McpRouter {
45
48
  : null;
46
49
  this.maxBatchSize = clientConfig.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
47
50
  this.toolResolver = new ToolResolver(Object.keys(servers));
51
+ this.tokenManager = new OAuth2TokenManager(logger);
48
52
  if (clientConfig.adaptivePromotion?.enabled) {
49
53
  this.promotion = new AdaptivePromotion(clientConfig.adaptivePromotion, logger);
50
54
  }
@@ -227,14 +231,8 @@ export class McpRouter {
227
231
  }
228
232
  }
229
233
  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
- });
234
+ const callOutcome = await this.callToolWithRetry(server, tool, params ?? {}, state.transport);
235
+ const response = callOutcome.response;
238
236
  if (response.error) {
239
237
  return this.error("mcp_error", response.error.message, undefined, response.error.code);
240
238
  }
@@ -248,7 +246,13 @@ export class McpRouter {
248
246
  if (this.resultCache && cacheKey) {
249
247
  this.resultCache.set(cacheKey, result);
250
248
  }
251
- return { server, action: "call", tool, result };
249
+ return {
250
+ server,
251
+ action: "call",
252
+ tool,
253
+ result,
254
+ ...(callOutcome.retries > 0 ? { retries: callOutcome.retries } : {})
255
+ };
252
256
  }
253
257
  catch (error) {
254
258
  return this.error("mcp_error", error instanceof Error ? error.message : String(error));
@@ -390,11 +394,104 @@ export class McpRouter {
390
394
  }));
391
395
  return { action: "promotions", promoted, stats };
392
396
  }
397
+ getRetryPolicy(server) {
398
+ const globalRetry = this.clientConfig.retry ?? {};
399
+ const serverRetry = this.servers[server].retry ?? {};
400
+ const maxAttemptsRaw = serverRetry.maxAttempts ?? globalRetry.maxAttempts ?? 1;
401
+ const delayMsRaw = serverRetry.delayMs ?? globalRetry.delayMs ?? 1000;
402
+ const backoffMultiplierRaw = serverRetry.backoffMultiplier ?? globalRetry.backoffMultiplier ?? 2;
403
+ const retryOn = serverRetry.retryOn ?? globalRetry.retryOn ?? ["timeout", "connection_error"];
404
+ return {
405
+ maxAttempts: Math.max(1, Math.floor(maxAttemptsRaw)),
406
+ delayMs: Math.max(0, Math.floor(delayMsRaw)),
407
+ backoffMultiplier: Math.max(1, backoffMultiplierRaw),
408
+ retryOn: new Set(retryOn)
409
+ };
410
+ }
411
+ classifyTransientError(error) {
412
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
413
+ if (message.includes("timeout") ||
414
+ message.includes("timed out") ||
415
+ message.includes("abort")) {
416
+ return "timeout";
417
+ }
418
+ if (message.includes("connection") ||
419
+ message.includes("econnreset") ||
420
+ message.includes("socket hang up") ||
421
+ message.includes("network") ||
422
+ message.includes("fetch failed") ||
423
+ message.includes("econnrefused") ||
424
+ message.includes("enotfound")) {
425
+ return "connection_error";
426
+ }
427
+ return null;
428
+ }
429
+ async callToolWithRetry(server, tool, args, transport) {
430
+ const retryPolicy = this.getRetryPolicy(server);
431
+ let retries = 0;
432
+ let lastError;
433
+ for (let attempt = 0; attempt < retryPolicy.maxAttempts; attempt++) {
434
+ try {
435
+ const response = await transport.sendRequest({
436
+ jsonrpc: "2.0",
437
+ method: "tools/call",
438
+ params: {
439
+ name: tool,
440
+ arguments: args
441
+ }
442
+ });
443
+ return { response, retries };
444
+ }
445
+ catch (error) {
446
+ lastError = error;
447
+ const category = this.classifyTransientError(error);
448
+ const shouldRetry = category !== null &&
449
+ retryPolicy.retryOn.has(category) &&
450
+ attempt < retryPolicy.maxAttempts - 1;
451
+ if (!shouldRetry) {
452
+ throw error;
453
+ }
454
+ retries += 1;
455
+ const delay = retryPolicy.delayMs * Math.pow(retryPolicy.backoffMultiplier, attempt);
456
+ if (delay > 0) {
457
+ await new Promise((resolve) => setTimeout(resolve, delay));
458
+ }
459
+ }
460
+ }
461
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
462
+ }
393
463
  async disconnectAll() {
394
464
  for (const serverName of Object.keys(this.servers)) {
395
465
  await this.disconnectServer(serverName);
396
466
  }
397
467
  }
468
+ async shutdown(timeoutMs = this.clientConfig.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS) {
469
+ const effectiveTimeout = Math.max(0, timeoutMs);
470
+ for (const [serverName, state] of this.states) {
471
+ if (state.idleTimer) {
472
+ clearTimeout(state.idleTimer);
473
+ state.idleTimer = null;
474
+ }
475
+ try {
476
+ if (state.transport.shutdown) {
477
+ await state.transport.shutdown(effectiveTimeout);
478
+ }
479
+ else {
480
+ await state.transport.disconnect();
481
+ }
482
+ }
483
+ catch (error) {
484
+ this.logger.warn(`[mcp-bridge] Router shutdown: failed to close ${serverName}:`, error);
485
+ }
486
+ }
487
+ this.states.clear();
488
+ this.toolResolver.clear();
489
+ if (this.intentRouter) {
490
+ this.intentRouter.clearIndex();
491
+ }
492
+ this.resultCache?.invalidate();
493
+ this.tokenManager.clear();
494
+ }
398
495
  async ensureConnected(server) {
399
496
  let state = this.states.get(server);
400
497
  if (!state) {
@@ -497,13 +594,13 @@ export class McpRouter {
497
594
  this.resultCache?.invalidate(`${serverName}:`);
498
595
  };
499
596
  if (serverConfig.transport === "sse") {
500
- return new this.transportRefs.sse(serverConfig, this.clientConfig, this.logger, onReconnected);
597
+ return new this.transportRefs.sse(serverConfig, this.clientConfig, this.logger, onReconnected, this.tokenManager);
501
598
  }
502
599
  if (serverConfig.transport === "stdio") {
503
600
  return new this.transportRefs.stdio(serverConfig, this.clientConfig, this.logger, onReconnected);
504
601
  }
505
602
  if (serverConfig.transport === "streamable-http") {
506
- return new this.transportRefs.streamableHttp(serverConfig, this.clientConfig, this.logger, onReconnected);
603
+ return new this.transportRefs.streamableHttp(serverConfig, this.clientConfig, this.logger, onReconnected, this.tokenManager);
507
604
  }
508
605
  throw new Error(`Unsupported transport: ${serverConfig.transport}`);
509
606
  }
@@ -0,0 +1,20 @@
1
+ import type { Logger } from "./types.js";
2
+ export interface OAuth2Config {
3
+ clientId: string;
4
+ clientSecret: string;
5
+ tokenUrl: string;
6
+ scopes?: string[];
7
+ audience?: string;
8
+ }
9
+ export declare class OAuth2TokenManager {
10
+ private readonly logger;
11
+ private readonly tokenCache;
12
+ private readonly inflight;
13
+ constructor(logger: Logger);
14
+ getToken(config: OAuth2Config): Promise<string>;
15
+ invalidate(tokenUrl: string, clientId: string): void;
16
+ clear(): void;
17
+ private makeKey;
18
+ private fetchToken;
19
+ private exchangeToken;
20
+ }
@@ -0,0 +1,98 @@
1
+ const DEFAULT_EXPIRES_IN_SECONDS = 3600;
2
+ const EXPIRY_BUFFER_SECONDS = 60;
3
+ export class OAuth2TokenManager {
4
+ logger;
5
+ tokenCache = new Map();
6
+ inflight = new Map();
7
+ constructor(logger) {
8
+ this.logger = logger;
9
+ }
10
+ async getToken(config) {
11
+ const key = this.makeKey(config.tokenUrl, config.clientId);
12
+ const now = Date.now();
13
+ const cached = this.tokenCache.get(key);
14
+ if (cached && cached.expiresAt > now) {
15
+ return cached.accessToken;
16
+ }
17
+ const existingInflight = this.inflight.get(key);
18
+ if (existingInflight) {
19
+ return existingInflight;
20
+ }
21
+ const requestPromise = this.fetchToken(config, cached)
22
+ .then((token) => {
23
+ this.tokenCache.set(key, token);
24
+ return token.accessToken;
25
+ })
26
+ .finally(() => {
27
+ this.inflight.delete(key);
28
+ });
29
+ this.inflight.set(key, requestPromise);
30
+ return requestPromise;
31
+ }
32
+ invalidate(tokenUrl, clientId) {
33
+ const key = this.makeKey(tokenUrl, clientId);
34
+ this.tokenCache.delete(key);
35
+ this.inflight.delete(key);
36
+ }
37
+ clear() {
38
+ this.tokenCache.clear();
39
+ this.inflight.clear();
40
+ }
41
+ makeKey(tokenUrl, clientId) {
42
+ return `${tokenUrl}::${clientId}`;
43
+ }
44
+ async fetchToken(config, cached) {
45
+ if (cached?.refreshToken) {
46
+ try {
47
+ return await this.exchangeToken(config, {
48
+ grant_type: "refresh_token",
49
+ refresh_token: cached.refreshToken,
50
+ });
51
+ }
52
+ catch (error) {
53
+ this.logger.warn("[mcp-bridge] OAuth2 refresh token exchange failed, falling back to client_credentials:", error);
54
+ }
55
+ }
56
+ return this.exchangeToken(config, {
57
+ grant_type: "client_credentials",
58
+ });
59
+ }
60
+ async exchangeToken(config, grant) {
61
+ const formData = new URLSearchParams();
62
+ formData.set("grant_type", grant.grant_type);
63
+ formData.set("client_id", config.clientId);
64
+ formData.set("client_secret", config.clientSecret);
65
+ if (grant.grant_type === "refresh_token") {
66
+ formData.set("refresh_token", grant.refresh_token);
67
+ }
68
+ if (config.scopes?.length) {
69
+ formData.set("scope", config.scopes.join(" "));
70
+ }
71
+ if (config.audience) {
72
+ formData.set("audience", config.audience);
73
+ }
74
+ const response = await fetch(config.tokenUrl, {
75
+ method: "POST",
76
+ headers: {
77
+ "Content-Type": "application/x-www-form-urlencoded",
78
+ },
79
+ body: formData.toString(),
80
+ });
81
+ if (!response.ok) {
82
+ throw new Error(`OAuth2 token exchange failed: HTTP ${response.status}`);
83
+ }
84
+ const payload = (await response.json());
85
+ if (!payload.access_token) {
86
+ throw new Error("OAuth2 token exchange response missing access_token");
87
+ }
88
+ const expiresIn = Number.isFinite(payload.expires_in)
89
+ ? Number(payload.expires_in)
90
+ : DEFAULT_EXPIRES_IN_SECONDS;
91
+ const expiresAt = Date.now() + Math.max(0, expiresIn - EXPIRY_BUFFER_SECONDS) * 1000;
92
+ return {
93
+ accessToken: payload.access_token,
94
+ expiresAt,
95
+ refreshToken: payload.refresh_token,
96
+ };
97
+ }
98
+ }
@@ -10,6 +10,7 @@ export declare class StandaloneServer {
10
10
  private router;
11
11
  private initialized;
12
12
  private lspMode;
13
+ private readonly tokenManager;
13
14
  private directTools;
14
15
  private directConnections;
15
16
  constructor(config: BridgeConfig, logger: Logger);
@@ -4,6 +4,7 @@ import { pickRegisteredToolName } from "./tool-naming.js";
4
4
  import { SseTransport } from "./transport-sse.js";
5
5
  import { StdioTransport } from "./transport-stdio.js";
6
6
  import { StreamableHttpTransport } from "./transport-streamable-http.js";
7
+ import { OAuth2TokenManager } from "./oauth2-token-manager.js";
7
8
  /**
8
9
  * Standalone MCP server that wraps the router.
9
10
  * Implements the MCP protocol (initialize, tools/list, tools/call)
@@ -15,12 +16,14 @@ export class StandaloneServer {
15
16
  router = null;
16
17
  initialized = false;
17
18
  lspMode = false;
19
+ tokenManager;
18
20
  // Direct mode state
19
21
  directTools = [];
20
22
  directConnections = new Map();
21
23
  constructor(config, logger) {
22
24
  this.config = config;
23
25
  this.logger = logger;
26
+ this.tokenManager = new OAuth2TokenManager(logger);
24
27
  if (this.isRouterMode()) {
25
28
  this.router = new McpRouter(config.servers || {}, config, logger);
26
29
  }
@@ -400,11 +403,11 @@ export class StandaloneServer {
400
403
  };
401
404
  switch (serverConfig.transport) {
402
405
  case "sse":
403
- return new SseTransport(serverConfig, this.config, this.logger, onReconnected);
406
+ return new SseTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager);
404
407
  case "stdio":
405
408
  return new StdioTransport(serverConfig, this.config, this.logger, onReconnected);
406
409
  case "streamable-http":
407
- return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected);
410
+ return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager);
408
411
  default:
409
412
  throw new Error(`Unsupported transport: ${serverConfig.transport}`);
410
413
  }
@@ -413,7 +416,7 @@ export class StandaloneServer {
413
416
  async shutdown() {
414
417
  this.logger.info("[mcp-bridge] Shutting down...");
415
418
  if (this.router) {
416
- await this.router.disconnectAll();
419
+ await this.router.shutdown(this.config.shutdownTimeoutMs);
417
420
  }
418
421
  for (const [name, conn] of this.directConnections) {
419
422
  try {
@@ -424,6 +427,7 @@ export class StandaloneServer {
424
427
  }
425
428
  }
426
429
  this.directConnections.clear();
430
+ this.tokenManager.clear();
427
431
  this.logger.info("[mcp-bridge] Shutdown complete");
428
432
  }
429
433
  }
@@ -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;
@@ -1,4 +1,5 @@
1
1
  import { McpTransport, McpRequest, McpResponse, McpServerConfig, McpClientConfig, Logger, JsonRpcMessage } from "./types.js";
2
+ import type { OAuth2Config, OAuth2TokenManager } from "./oauth2-token-manager.js";
2
3
  export type PendingRequest = {
3
4
  resolve: (value: McpResponse) => void;
4
5
  reject: (reason: Error) => void;
@@ -70,6 +71,17 @@ export declare function resolveEnvRecord(record: Record<string, string>, context
70
71
  * @param extraEnv - Additional env vars to check before process.env
71
72
  */
72
73
  export declare function resolveArgs(args: string[], extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): string[];
74
+ /**
75
+ * Resolve auth config into HTTP headers.
76
+ */
77
+ export declare function resolveAuthHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
78
+ export declare function resolveOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): OAuth2Config;
79
+ export declare function resolveAuthHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Promise<Record<string, string>>;
80
+ /**
81
+ * Resolve server headers and merge auth headers (auth takes precedence).
82
+ */
83
+ export declare function resolveServerHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
84
+ export declare function resolveServerHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Promise<Record<string, string>>;
73
85
  /**
74
86
  * Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
75
87
  */
@@ -159,6 +159,59 @@ 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
+ if (config.auth.type === "header") {
173
+ return resolveEnvRecord(config.auth.headers, "auth header", extraEnv, envFallback);
174
+ }
175
+ throw new Error("[mcp-bridge] OAuth2 auth requires async header resolution via resolveAuthHeadersAsync");
176
+ }
177
+ export function resolveOAuth2Config(config, extraEnv, envFallback) {
178
+ if (!config.auth || config.auth.type !== "oauth2") {
179
+ throw new Error("[mcp-bridge] resolveOAuth2Config called for non-oauth2 auth config");
180
+ }
181
+ const scopes = config.auth.scopes?.map((scope, index) => resolveEnvVars(scope, `oauth2 scope[${index}]`, extraEnv, envFallback));
182
+ return {
183
+ clientId: resolveEnvVars(config.auth.clientId, "oauth2 clientId", extraEnv, envFallback),
184
+ clientSecret: resolveEnvVars(config.auth.clientSecret, "oauth2 clientSecret", extraEnv, envFallback),
185
+ tokenUrl: resolveEnvVars(config.auth.tokenUrl, "oauth2 tokenUrl", extraEnv, envFallback),
186
+ ...(scopes && scopes.length > 0 ? { scopes } : {}),
187
+ ...(config.auth.audience
188
+ ? { audience: resolveEnvVars(config.auth.audience, "oauth2 audience", extraEnv, envFallback) }
189
+ : {}),
190
+ };
191
+ }
192
+ export async function resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback) {
193
+ if (!config.auth)
194
+ return {};
195
+ if (config.auth.type === "oauth2") {
196
+ const oauth2Config = resolveOAuth2Config(config, extraEnv, envFallback);
197
+ const token = await tokenManager.getToken(oauth2Config);
198
+ return { Authorization: `Bearer ${token}` };
199
+ }
200
+ return resolveAuthHeaders(config, extraEnv, envFallback);
201
+ }
202
+ /**
203
+ * Resolve server headers and merge auth headers (auth takes precedence).
204
+ */
205
+ export function resolveServerHeaders(config, extraEnv, envFallback) {
206
+ const base = resolveEnvRecord(config.headers || {}, "header", extraEnv, envFallback);
207
+ const auth = resolveAuthHeaders(config, extraEnv, envFallback);
208
+ return { ...base, ...auth };
209
+ }
210
+ export async function resolveServerHeadersAsync(config, tokenManager, extraEnv, envFallback) {
211
+ const base = resolveEnvRecord(config.headers || {}, "header", extraEnv, envFallback);
212
+ const auth = await resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback);
213
+ return { ...base, ...auth };
214
+ }
162
215
  /**
163
216
  * Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
164
217
  */
@@ -1,16 +1,25 @@
1
- import { McpRequest, McpResponse } from "./types.js";
1
+ import { Logger, McpClientConfig, McpRequest, McpResponse, McpServerConfig } from "./types.js";
2
+ import { OAuth2TokenManager } from "./oauth2-token-manager.js";
2
3
  import { BaseTransport } from "./transport-base.js";
3
4
  export declare class SseTransport extends BaseTransport {
4
5
  private endpointUrl;
5
6
  private sseAbortController;
6
7
  private resolvedHeaders;
8
+ private pendingRequestControllers;
9
+ private readonly tokenManager;
7
10
  protected get transportName(): string;
11
+ constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager);
8
12
  connect(): Promise<void>;
9
13
  private _onEndpointReceived;
14
+ private getBaseHeaders;
15
+ private refreshResolvedHeaders;
16
+ private invalidateOAuth2Token;
17
+ private fetchWithOAuthRetry;
10
18
  private startEventStream;
11
19
  private processEventLine;
12
20
  sendNotification(notification: any): Promise<void>;
13
21
  sendRequest(request: McpRequest): Promise<McpResponse>;
14
22
  private isSameOrigin;
15
23
  disconnect(): Promise<void>;
24
+ shutdown(): Promise<void>;
16
25
  }
@@ -1,17 +1,23 @@
1
1
  import { nextRequestId } from "./types.js";
2
- import { BaseTransport, resolveEnvRecord, warnIfNonTlsRemoteUrl } from "./transport-base.js";
2
+ import { OAuth2TokenManager } from "./oauth2-token-manager.js";
3
+ import { BaseTransport, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
3
4
  export class SseTransport extends BaseTransport {
4
5
  endpointUrl = null;
5
6
  sseAbortController = null;
6
7
  resolvedHeaders = null;
8
+ pendingRequestControllers = new Map();
9
+ tokenManager;
7
10
  get transportName() { return "SSE"; }
11
+ constructor(config, clientConfig, logger, onReconnected, tokenManager) {
12
+ super(config, clientConfig, logger, onReconnected);
13
+ this.tokenManager = tokenManager ?? new OAuth2TokenManager(logger);
14
+ }
8
15
  async connect() {
9
16
  if (!this.config.url) {
10
17
  throw new Error("SSE transport requires URL");
11
18
  }
12
19
  warnIfNonTlsRemoteUrl(this.config.url, this.logger);
13
- // Resolve headers once and cache for all subsequent requests
14
- this.resolvedHeaders = resolveEnvRecord(this.config.headers || {}, "header");
20
+ await this.refreshResolvedHeaders();
15
21
  if (this.sseAbortController) {
16
22
  this.sseAbortController.abort();
17
23
  }
@@ -23,7 +29,7 @@ export class SseTransport extends BaseTransport {
23
29
  });
24
30
  // Fire and forget the stream reader
25
31
  this.startEventStream().catch((error) => {
26
- if (error instanceof Error && error.name !== 'AbortError') {
32
+ if (error instanceof Error && error.name !== "AbortError") {
27
33
  this.logger.error("[mcp-bridge] SSE stream error:", error.message);
28
34
  this.scheduleReconnect();
29
35
  }
@@ -33,16 +39,58 @@ export class SseTransport extends BaseTransport {
33
39
  this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
34
40
  }
35
41
  _onEndpointReceived = null;
42
+ async getBaseHeaders(forceRefresh = false) {
43
+ if (this.config.auth?.type === "oauth2") {
44
+ if (forceRefresh) {
45
+ this.invalidateOAuth2Token();
46
+ }
47
+ return this.refreshResolvedHeaders();
48
+ }
49
+ if (!this.resolvedHeaders) {
50
+ this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
51
+ }
52
+ return this.resolvedHeaders;
53
+ }
54
+ async refreshResolvedHeaders() {
55
+ if (this.config.auth?.type === "oauth2") {
56
+ this.resolvedHeaders = await resolveServerHeadersAsync(this.config, this.tokenManager, undefined, this.clientConfig.envFallback);
57
+ }
58
+ else {
59
+ this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
60
+ }
61
+ return this.resolvedHeaders;
62
+ }
63
+ invalidateOAuth2Token() {
64
+ if (this.config.auth?.type !== "oauth2") {
65
+ return;
66
+ }
67
+ const oauth2Config = resolveOAuth2Config(this.config, undefined, this.clientConfig.envFallback);
68
+ this.tokenManager.invalidate(oauth2Config.tokenUrl, oauth2Config.clientId);
69
+ }
70
+ async fetchWithOAuthRetry(url, init) {
71
+ const response = await fetch(url, init);
72
+ if (response.status !== 401 || this.config.auth?.type !== "oauth2") {
73
+ return response;
74
+ }
75
+ this.logger.warn("[mcp-bridge] SSE request returned 401, invalidating OAuth2 token and retrying once");
76
+ const refreshedBase = await this.getBaseHeaders(true);
77
+ const retryHeaders = {
78
+ ...refreshedBase,
79
+ ...Object.fromEntries(Array.from(new Headers(init.headers).entries()).filter(([key]) => key.toLowerCase() !== "authorization")),
80
+ Authorization: refreshedBase.Authorization,
81
+ };
82
+ return fetch(url, { ...init, headers: retryHeaders });
83
+ }
36
84
  async startEventStream() {
37
85
  if (!this.config.url)
38
86
  return;
39
- const base = this.resolvedHeaders ?? resolveEnvRecord(this.config.headers || {}, "header");
40
- const headers = { ...base, "Accept": "text/event-stream" };
87
+ const base = await this.getBaseHeaders();
88
+ const headers = { ...base, Accept: "text/event-stream" };
41
89
  try {
42
- const response = await fetch(this.config.url, {
90
+ const response = await this.fetchWithOAuthRetry(this.config.url, {
43
91
  method: "GET",
44
92
  headers,
45
- signal: this.sseAbortController?.signal
93
+ signal: this.sseAbortController?.signal,
46
94
  });
47
95
  if (!response.ok) {
48
96
  throw new Error(`SSE connection failed: HTTP ${response.status}`);
@@ -59,7 +107,7 @@ export class SseTransport extends BaseTransport {
59
107
  if (done)
60
108
  break;
61
109
  buffer += decoder.decode(value, { stream: true });
62
- const lines = buffer.split('\n');
110
+ const lines = buffer.split("\n");
63
111
  buffer = lines.pop() || "";
64
112
  for (const line of lines) {
65
113
  this.processEventLine(line, state);
@@ -70,7 +118,7 @@ export class SseTransport extends BaseTransport {
70
118
  this.scheduleReconnect();
71
119
  }
72
120
  catch (error) {
73
- if (error instanceof Error && error.name === 'AbortError')
121
+ if (error instanceof Error && error.name === "AbortError")
74
122
  return;
75
123
  this.logger.error("SSE stream error:", error);
76
124
  this.scheduleReconnect();
@@ -130,12 +178,12 @@ export class SseTransport extends BaseTransport {
130
178
  if (!this.connected || !this.endpointUrl) {
131
179
  throw new Error("SSE transport not connected or no endpoint URL");
132
180
  }
133
- const base = this.resolvedHeaders ?? resolveEnvRecord(this.config.headers || {}, "header");
181
+ const base = await this.getBaseHeaders();
134
182
  const headers = { ...base, "Content-Type": "application/json" };
135
- const response = await fetch(this.endpointUrl, {
183
+ const response = await this.fetchWithOAuthRetry(this.endpointUrl, {
136
184
  method: "POST",
137
185
  headers,
138
- body: JSON.stringify(notification)
186
+ body: JSON.stringify(notification),
139
187
  });
140
188
  if (!response.ok) {
141
189
  this.logger.warn(`[mcp-bridge] SSE notification got HTTP ${response.status}`);
@@ -150,32 +198,41 @@ export class SseTransport extends BaseTransport {
150
198
  return new Promise((resolve, reject) => {
151
199
  const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
152
200
  const timeout = setTimeout(() => {
201
+ this.pendingRequestControllers.get(id)?.abort();
202
+ this.pendingRequestControllers.delete(id);
153
203
  this.pendingRequests.delete(id);
154
204
  reject(new Error(`Request timeout after ${requestTimeout}ms`));
155
205
  }, requestTimeout);
156
206
  this.pendingRequests.set(id, { resolve, reject, timeout });
157
- const base = this.resolvedHeaders ?? resolveEnvRecord(this.config.headers || {}, "header");
158
- const headers = { ...base, "Content-Type": "application/json" };
207
+ const abortController = new AbortController();
208
+ this.pendingRequestControllers.set(id, abortController);
159
209
  // The response arrives via the SSE stream (handleMessage), not from this fetch.
160
210
  // The fetch only confirms the server accepted the request (HTTP 200).
161
211
  // If the fetch fails, we reject immediately; otherwise we wait for the SSE stream.
162
- fetch(this.endpointUrl, {
163
- method: "POST",
164
- headers,
165
- body: JSON.stringify(requestWithId)
166
- })
167
- .then((response) => {
168
- if (!response.ok) {
212
+ (async () => {
213
+ try {
214
+ const base = await this.getBaseHeaders();
215
+ const headers = { ...base, "Content-Type": "application/json" };
216
+ const response = await this.fetchWithOAuthRetry(this.endpointUrl, {
217
+ method: "POST",
218
+ headers,
219
+ body: JSON.stringify(requestWithId),
220
+ signal: abortController.signal,
221
+ });
222
+ this.pendingRequestControllers.delete(id);
223
+ if (!response.ok) {
224
+ clearTimeout(timeout);
225
+ this.pendingRequests.delete(id);
226
+ reject(new Error(`HTTP ${response.status}`));
227
+ }
228
+ }
229
+ catch (error) {
230
+ this.pendingRequestControllers.delete(id);
169
231
  clearTimeout(timeout);
170
232
  this.pendingRequests.delete(id);
171
- reject(new Error(`HTTP ${response.status}`));
233
+ reject(error);
172
234
  }
173
- })
174
- .catch((error) => {
175
- clearTimeout(timeout);
176
- this.pendingRequests.delete(id);
177
- reject(error);
178
- });
235
+ })();
179
236
  });
180
237
  }
181
238
  isSameOrigin(url) {
@@ -197,6 +254,13 @@ export class SseTransport extends BaseTransport {
197
254
  this.sseAbortController.abort();
198
255
  this.sseAbortController = null;
199
256
  }
257
+ for (const [, controller] of this.pendingRequestControllers) {
258
+ controller.abort();
259
+ }
260
+ this.pendingRequestControllers.clear();
200
261
  this.rejectAllPending("Connection closed");
201
262
  }
263
+ async shutdown() {
264
+ await this.disconnect();
265
+ }
202
266
  }
@@ -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
  }
@@ -1,11 +1,21 @@
1
- import { McpRequest, McpResponse } from "./types.js";
1
+ import { Logger, McpClientConfig, McpRequest, McpResponse, McpServerConfig } from "./types.js";
2
+ import { OAuth2TokenManager } from "./oauth2-token-manager.js";
2
3
  import { BaseTransport } from "./transport-base.js";
3
4
  export declare class StreamableHttpTransport extends BaseTransport {
4
5
  private sessionId?;
6
+ private resolvedHeaders;
7
+ private pendingRequestControllers;
8
+ private readonly tokenManager;
5
9
  protected get transportName(): string;
10
+ constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager);
6
11
  connect(): Promise<void>;
12
+ private getBaseHeaders;
13
+ private refreshResolvedHeaders;
14
+ private invalidateOAuth2Token;
15
+ private fetchWithOAuthRetry;
7
16
  sendRequest(request: McpRequest): Promise<McpResponse>;
8
17
  sendNotification(notification: any): Promise<void>;
9
18
  private probeServer;
10
19
  disconnect(): Promise<void>;
20
+ shutdown(): Promise<void>;
11
21
  }
@@ -1,20 +1,69 @@
1
1
  import { nextRequestId } from "./types.js";
2
- import { BaseTransport, resolveEnvRecord, warnIfNonTlsRemoteUrl } from "./transport-base.js";
2
+ import { OAuth2TokenManager } from "./oauth2-token-manager.js";
3
+ import { BaseTransport, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
3
4
  export class StreamableHttpTransport extends BaseTransport {
4
5
  sessionId;
6
+ resolvedHeaders = null;
7
+ pendingRequestControllers = new Map();
8
+ tokenManager;
5
9
  get transportName() { return "streamable-http"; }
10
+ constructor(config, clientConfig, logger, onReconnected, tokenManager) {
11
+ super(config, clientConfig, logger, onReconnected);
12
+ this.tokenManager = tokenManager ?? new OAuth2TokenManager(logger);
13
+ }
6
14
  async connect() {
7
15
  if (!this.config.url) {
8
16
  throw new Error("Streamable HTTP transport requires URL");
9
17
  }
10
18
  warnIfNonTlsRemoteUrl(this.config.url, this.logger);
11
- // Validate that all header env vars resolve (fail fast)
12
- resolveEnvRecord(this.config.headers || {}, "header");
19
+ await this.refreshResolvedHeaders();
13
20
  await this.probeServer();
14
21
  this.connected = true;
15
22
  this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
16
23
  this.logger.info(`[mcp-bridge] Streamable HTTP transport ready for ${this.config.url}`);
17
24
  }
25
+ async getBaseHeaders(forceRefresh = false) {
26
+ if (this.config.auth?.type === "oauth2") {
27
+ if (forceRefresh) {
28
+ this.invalidateOAuth2Token();
29
+ }
30
+ return this.refreshResolvedHeaders();
31
+ }
32
+ if (!this.resolvedHeaders) {
33
+ this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
34
+ }
35
+ return this.resolvedHeaders;
36
+ }
37
+ async refreshResolvedHeaders() {
38
+ if (this.config.auth?.type === "oauth2") {
39
+ this.resolvedHeaders = await resolveServerHeadersAsync(this.config, this.tokenManager, undefined, this.clientConfig.envFallback);
40
+ }
41
+ else {
42
+ this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
43
+ }
44
+ return this.resolvedHeaders;
45
+ }
46
+ invalidateOAuth2Token() {
47
+ if (this.config.auth?.type !== "oauth2") {
48
+ return;
49
+ }
50
+ const oauth2Config = resolveOAuth2Config(this.config, undefined, this.clientConfig.envFallback);
51
+ this.tokenManager.invalidate(oauth2Config.tokenUrl, oauth2Config.clientId);
52
+ }
53
+ async fetchWithOAuthRetry(url, init) {
54
+ const response = await fetch(url, init);
55
+ if (response.status !== 401 || this.config.auth?.type !== "oauth2") {
56
+ return response;
57
+ }
58
+ this.logger.warn("[mcp-bridge] Streamable HTTP request returned 401, invalidating OAuth2 token and retrying once");
59
+ const refreshedBase = await this.getBaseHeaders(true);
60
+ const retryHeaders = {
61
+ ...refreshedBase,
62
+ ...Object.fromEntries(Array.from(new Headers(init.headers).entries()).filter(([key]) => key.toLowerCase() !== "authorization")),
63
+ Authorization: refreshedBase.Authorization,
64
+ };
65
+ return fetch(url, { ...init, headers: retryHeaders });
66
+ }
18
67
  async sendRequest(request) {
19
68
  if (!this.connected || !this.config.url) {
20
69
  throw new Error("Streamable HTTP transport not connected");
@@ -23,108 +72,120 @@ export class StreamableHttpTransport extends BaseTransport {
23
72
  const requestWithId = { ...request, id };
24
73
  return new Promise((resolve, reject) => {
25
74
  const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
75
+ const abortController = new AbortController();
26
76
  const timeout = setTimeout(() => {
77
+ abortController.abort();
78
+ this.pendingRequestControllers.delete(id);
27
79
  this.pendingRequests.delete(id);
28
80
  reject(new Error(`Request timeout after ${requestTimeout}ms`));
29
81
  }, requestTimeout);
30
82
  this.pendingRequests.set(id, { resolve, reject, timeout });
31
- const headers = resolveEnvRecord({
32
- "Accept": "application/json, text/event-stream",
33
- ...this.config.headers,
34
- "Content-Type": "application/json"
35
- }, "header");
36
- if (this.sessionId) {
37
- headers["mcp-session-id"] = this.sessionId;
38
- }
39
- fetch(this.config.url, {
40
- method: "POST",
41
- headers,
42
- body: JSON.stringify(requestWithId)
43
- })
44
- .then(async (response) => {
45
- const responseSessionId = response.headers.get("mcp-session-id");
46
- if (responseSessionId) {
47
- this.sessionId = responseSessionId;
48
- }
49
- if (!response.ok) {
50
- clearTimeout(timeout);
51
- this.pendingRequests.delete(id);
52
- reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
53
- return;
54
- }
83
+ this.pendingRequestControllers.set(id, abortController);
84
+ (async () => {
55
85
  try {
56
- const contentType = response.headers.get("content-type") || "";
57
- let jsonResponse;
58
- if (contentType.includes("text/event-stream")) {
59
- const text = await response.text();
60
- const lines = text.split('\n');
61
- // SSE event boundary parsing: collect data lines, dispatch on empty line
62
- let dataBuffer = [];
63
- const dispatch = () => {
64
- if (dataBuffer.length === 0)
65
- return;
66
- const data = dataBuffer.join("\n");
67
- dataBuffer = [];
68
- try {
69
- this.handleMessage(JSON.parse(data));
70
- }
71
- catch { /* skip malformed events */ }
72
- };
73
- let hasData = false;
74
- for (const line of lines) {
75
- const trimmed = line.trim();
76
- if (trimmed.startsWith("data:")) {
77
- dataBuffer.push(trimmed.substring(5).trimStart());
78
- hasData = true;
86
+ const base = await this.getBaseHeaders();
87
+ const headers = {
88
+ ...base,
89
+ Accept: "application/json, text/event-stream",
90
+ "Content-Type": "application/json",
91
+ };
92
+ if (this.sessionId) {
93
+ headers["mcp-session-id"] = this.sessionId;
94
+ }
95
+ const response = await this.fetchWithOAuthRetry(this.config.url, {
96
+ method: "POST",
97
+ headers,
98
+ body: JSON.stringify(requestWithId),
99
+ signal: abortController.signal,
100
+ });
101
+ this.pendingRequestControllers.delete(id);
102
+ const responseSessionId = response.headers.get("mcp-session-id");
103
+ if (responseSessionId) {
104
+ this.sessionId = responseSessionId;
105
+ }
106
+ if (!response.ok) {
107
+ clearTimeout(timeout);
108
+ this.pendingRequests.delete(id);
109
+ reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
110
+ return;
111
+ }
112
+ try {
113
+ const contentType = response.headers.get("content-type") || "";
114
+ if (contentType.includes("text/event-stream")) {
115
+ const text = await response.text();
116
+ const lines = text.split("\n");
117
+ // SSE event boundary parsing: collect data lines, dispatch on empty line
118
+ let dataBuffer = [];
119
+ const dispatch = () => {
120
+ if (dataBuffer.length === 0)
121
+ return;
122
+ const data = dataBuffer.join("\n");
123
+ dataBuffer = [];
124
+ try {
125
+ this.handleMessage(JSON.parse(data));
126
+ }
127
+ catch {
128
+ // skip malformed events
129
+ }
130
+ };
131
+ let hasData = false;
132
+ for (const line of lines) {
133
+ const trimmed = line.trim();
134
+ if (trimmed.startsWith("data:")) {
135
+ dataBuffer.push(trimmed.substring(5).trimStart());
136
+ hasData = true;
137
+ }
138
+ else if (trimmed === "" && dataBuffer.length > 0) {
139
+ dispatch();
140
+ }
79
141
  }
80
- else if (trimmed === "" && dataBuffer.length > 0) {
81
- dispatch();
142
+ // Dispatch any trailing data (server may omit final empty line)
143
+ dispatch();
144
+ if (!hasData) {
145
+ throw new Error("No data lines in SSE response");
82
146
  }
83
147
  }
84
- // Dispatch any trailing data (server may omit final empty line)
85
- dispatch();
86
- if (!hasData) {
87
- throw new Error("No data lines in SSE response");
148
+ else {
149
+ this.handleMessage(await response.json());
88
150
  }
89
151
  }
90
- else {
91
- this.handleMessage(await response.json());
152
+ catch (error) {
153
+ clearTimeout(timeout);
154
+ this.pendingRequests.delete(id);
155
+ reject(new Error("Failed to parse response: " + (error instanceof Error ? error.message : String(error))));
92
156
  }
93
157
  }
94
158
  catch (error) {
159
+ this.pendingRequestControllers.delete(id);
95
160
  clearTimeout(timeout);
96
161
  this.pendingRequests.delete(id);
97
- reject(new Error("Failed to parse response: " + (error instanceof Error ? error.message : String(error))));
98
- }
99
- })
100
- .catch(error => {
101
- clearTimeout(timeout);
102
- this.pendingRequests.delete(id);
103
- if (error.name === 'TypeError' && error.message.includes('fetch')) {
104
- this.logger.error("Connection error, scheduling reconnect:", error.message);
105
- this.scheduleReconnect();
162
+ if (error instanceof Error && error.name === "TypeError" && error.message.includes("fetch")) {
163
+ this.logger.error("Connection error, scheduling reconnect:", error.message);
164
+ this.scheduleReconnect();
165
+ }
166
+ reject(error);
106
167
  }
107
- reject(error);
108
- });
168
+ })();
109
169
  });
110
170
  }
111
171
  async sendNotification(notification) {
112
172
  if (!this.connected || !this.config.url) {
113
173
  throw new Error("Streamable HTTP transport not connected");
114
174
  }
115
- const headers = resolveEnvRecord({
116
- "Accept": "application/json, text/event-stream",
117
- ...this.config.headers,
118
- "Content-Type": "application/json"
119
- }, "header");
175
+ const base = await this.getBaseHeaders();
176
+ const headers = {
177
+ ...base,
178
+ Accept: "application/json, text/event-stream",
179
+ "Content-Type": "application/json",
180
+ };
120
181
  if (this.sessionId) {
121
182
  headers["mcp-session-id"] = this.sessionId;
122
183
  }
123
184
  try {
124
- const response = await fetch(this.config.url, {
185
+ const response = await this.fetchWithOAuthRetry(this.config.url, {
125
186
  method: "POST",
126
187
  headers,
127
- body: JSON.stringify(notification)
188
+ body: JSON.stringify(notification),
128
189
  });
129
190
  const responseSessionId = response.headers.get("mcp-session-id");
130
191
  if (responseSessionId) {
@@ -135,7 +196,7 @@ export class StreamableHttpTransport extends BaseTransport {
135
196
  }
136
197
  }
137
198
  catch (error) {
138
- if (error instanceof Error && error.name === 'TypeError' && error.message.includes('fetch')) {
199
+ if (error instanceof Error && error.name === "TypeError" && error.message.includes("fetch")) {
139
200
  this.logger.error("Connection error during notification, scheduling reconnect:", error.message);
140
201
  this.scheduleReconnect();
141
202
  }
@@ -146,11 +207,11 @@ export class StreamableHttpTransport extends BaseTransport {
146
207
  if (!this.config.url)
147
208
  return;
148
209
  try {
149
- const optionsResponse = await fetch(this.config.url, { method: "OPTIONS" });
210
+ const headers = await this.getBaseHeaders();
211
+ const optionsResponse = await this.fetchWithOAuthRetry(this.config.url, { method: "OPTIONS", headers });
150
212
  if (optionsResponse.ok)
151
213
  return;
152
- const headers = resolveEnvRecord(this.config.headers || {}, "header");
153
- const headResponse = await fetch(this.config.url, { method: "HEAD", headers });
214
+ const headResponse = await this.fetchWithOAuthRetry(this.config.url, { method: "HEAD", headers });
154
215
  if (!headResponse.ok) {
155
216
  this.logger.warn(`[mcp-bridge] Streamable HTTP server probe: OPTIONS ${optionsResponse.status}, HEAD ${headResponse.status} (non-blocking, connection continues)`);
156
217
  }
@@ -162,14 +223,18 @@ export class StreamableHttpTransport extends BaseTransport {
162
223
  async disconnect() {
163
224
  this.connected = false;
164
225
  this.cleanupReconnectTimer();
226
+ for (const [, controller] of this.pendingRequestControllers) {
227
+ controller.abort();
228
+ }
229
+ this.pendingRequestControllers.clear();
165
230
  // Send DELETE request if we have a session to clean up
166
231
  if (this.sessionId && this.config.url) {
167
232
  try {
168
- const headers = resolveEnvRecord(this.config.headers || {}, "header");
169
- headers["mcp-session-id"] = this.sessionId;
170
- await fetch(this.config.url, {
233
+ const base = await this.getBaseHeaders();
234
+ const headers = { ...base, "mcp-session-id": this.sessionId };
235
+ await this.fetchWithOAuthRetry(this.config.url, {
171
236
  method: "DELETE",
172
- headers
237
+ headers,
173
238
  });
174
239
  this.sessionId = undefined;
175
240
  this.logger.info("Streamable HTTP session cleaned up");
@@ -180,4 +245,7 @@ export class StreamableHttpTransport extends BaseTransport {
180
245
  }
181
246
  this.rejectAllPending("Connection closed");
182
247
  }
248
+ async shutdown() {
249
+ await this.disconnect();
250
+ }
183
251
  }
@@ -4,12 +4,33 @@ 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
+ type: "oauth2";
15
+ clientId: string;
16
+ clientSecret: string;
17
+ tokenUrl: string;
18
+ scopes?: string[];
19
+ audience?: string;
20
+ };
21
+ export interface RetryConfig {
22
+ maxAttempts?: number;
23
+ delayMs?: number;
24
+ backoffMultiplier?: number;
25
+ retryOn?: Array<"timeout" | "connection_error">;
26
+ }
7
27
  export interface McpServerConfig {
8
28
  transport: "sse" | "stdio" | "streamable-http";
9
29
  /** Human-readable description for router tool description generation */
10
30
  description?: string;
11
31
  url?: string;
12
32
  headers?: Record<string, string>;
33
+ auth?: HttpAuthConfig;
13
34
  command?: string;
14
35
  args?: string[];
15
36
  env?: Record<string, string>;
@@ -20,6 +41,7 @@ export interface McpServerConfig {
20
41
  allow?: string[];
21
42
  };
22
43
  maxResultChars?: number;
44
+ retry?: RetryConfig;
23
45
  }
24
46
  export interface McpClientConfig {
25
47
  servers: Record<string, McpServerConfig>;
@@ -28,6 +50,7 @@ export interface McpClientConfig {
28
50
  reconnectIntervalMs?: number;
29
51
  connectionTimeoutMs?: number;
30
52
  requestTimeoutMs?: number;
53
+ shutdownTimeoutMs?: number;
31
54
  routerIdleTimeoutMs?: number;
32
55
  routerMaxConcurrent?: number;
33
56
  maxBatchSize?: number;
@@ -49,6 +72,7 @@ export interface McpClientConfig {
49
72
  minCalls?: number;
50
73
  decayMs?: number;
51
74
  };
75
+ retry?: RetryConfig;
52
76
  resultCache?: {
53
77
  enabled?: boolean;
54
78
  maxEntries?: number;
@@ -98,6 +122,7 @@ export interface McpResponse {
98
122
  export interface McpTransport {
99
123
  connect(): Promise<void>;
100
124
  disconnect(): Promise<void>;
125
+ shutdown?(timeoutMs?: number): Promise<void>;
101
126
  sendRequest(request: McpRequest): Promise<McpResponse>;
102
127
  sendNotification(notification: any): Promise<void>;
103
128
  isConnected(): boolean;
@@ -112,9 +137,6 @@ export interface McpServerConnection {
112
137
  /** Bridge-level config loaded from ~/.mcp-bridge/config.json */
113
138
  export interface BridgeConfig extends McpClientConfig {
114
139
  http?: {
115
- auth?: {
116
- type: "bearer";
117
- token: string;
118
- };
140
+ auth?: HttpAuthConfig;
119
141
  };
120
142
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "1.9.0",
3
+ "version": "2.1.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",