@aiwerk/mcp-bridge 2.0.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, resolveAuthHeaders, resolveServerHeaders, 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";
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, resolveAuthHeaders, resolveServerHeaders, 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;
@@ -92,9 +93,9 @@ export type RouterDispatchResponse = {
92
93
  code?: number;
93
94
  };
94
95
  export interface RouterTransportRefs {
95
- 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;
96
97
  stdio: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>) => McpTransport;
97
- 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;
98
99
  }
99
100
  export declare class McpRouter {
100
101
  private readonly servers;
@@ -107,6 +108,7 @@ export declare class McpRouter {
107
108
  private readonly maxBatchSize;
108
109
  private readonly states;
109
110
  private readonly toolResolver;
111
+ private readonly tokenManager;
110
112
  private intentRouter;
111
113
  private promotion;
112
114
  constructor(servers: Record<string, McpServerConfig>, clientConfig: McpClientConfig, logger: Logger, transportRefs?: Partial<RouterTransportRefs>);
@@ -9,6 +9,7 @@ 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;
@@ -24,6 +25,7 @@ export class McpRouter {
24
25
  maxBatchSize;
25
26
  states = new Map();
26
27
  toolResolver;
28
+ tokenManager;
27
29
  intentRouter = null;
28
30
  promotion = null;
29
31
  constructor(servers, clientConfig, logger, transportRefs) {
@@ -46,6 +48,7 @@ export class McpRouter {
46
48
  : null;
47
49
  this.maxBatchSize = clientConfig.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
48
50
  this.toolResolver = new ToolResolver(Object.keys(servers));
51
+ this.tokenManager = new OAuth2TokenManager(logger);
49
52
  if (clientConfig.adaptivePromotion?.enabled) {
50
53
  this.promotion = new AdaptivePromotion(clientConfig.adaptivePromotion, logger);
51
54
  }
@@ -487,6 +490,7 @@ export class McpRouter {
487
490
  this.intentRouter.clearIndex();
488
491
  }
489
492
  this.resultCache?.invalidate();
493
+ this.tokenManager.clear();
490
494
  }
491
495
  async ensureConnected(server) {
492
496
  let state = this.states.get(server);
@@ -590,13 +594,13 @@ export class McpRouter {
590
594
  this.resultCache?.invalidate(`${serverName}:`);
591
595
  };
592
596
  if (serverConfig.transport === "sse") {
593
- 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);
594
598
  }
595
599
  if (serverConfig.transport === "stdio") {
596
600
  return new this.transportRefs.stdio(serverConfig, this.clientConfig, this.logger, onReconnected);
597
601
  }
598
602
  if (serverConfig.transport === "streamable-http") {
599
- 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);
600
604
  }
601
605
  throw new Error(`Unsupported transport: ${serverConfig.transport}`);
602
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
  }
@@ -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
  }
@@ -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;
@@ -74,10 +75,13 @@ export declare function resolveArgs(args: string[], extraEnv?: Record<string, st
74
75
  * Resolve auth config into HTTP headers.
75
76
  */
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>>;
77
80
  /**
78
81
  * Resolve server headers and merge auth headers (auth takes precedence).
79
82
  */
80
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>>;
81
85
  /**
82
86
  * Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
83
87
  */
@@ -169,7 +169,35 @@ export function resolveAuthHeaders(config, extraEnv, envFallback) {
169
169
  const token = resolveEnvVars(config.auth.token, "auth token", extraEnv, envFallback);
170
170
  return { Authorization: `Bearer ${token}` };
171
171
  }
172
- return resolveEnvRecord(config.auth.headers, "auth header", extraEnv, envFallback);
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);
173
201
  }
174
202
  /**
175
203
  * Resolve server headers and merge auth headers (auth takes precedence).
@@ -179,6 +207,11 @@ export function resolveServerHeaders(config, extraEnv, envFallback) {
179
207
  const auth = resolveAuthHeaders(config, extraEnv, envFallback);
180
208
  return { ...base, ...auth };
181
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
+ }
182
215
  /**
183
216
  * Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
184
217
  */
@@ -1,13 +1,20 @@
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;
7
8
  private pendingRequestControllers;
9
+ private readonly tokenManager;
8
10
  protected get transportName(): string;
11
+ constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager);
9
12
  connect(): Promise<void>;
10
13
  private _onEndpointReceived;
14
+ private getBaseHeaders;
15
+ private refreshResolvedHeaders;
16
+ private invalidateOAuth2Token;
17
+ private fetchWithOAuthRetry;
11
18
  private startEventStream;
12
19
  private processEventLine;
13
20
  sendNotification(notification: any): Promise<void>;
@@ -1,18 +1,23 @@
1
1
  import { nextRequestId } from "./types.js";
2
- import { BaseTransport, resolveServerHeaders, 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;
7
8
  pendingRequestControllers = new Map();
9
+ tokenManager;
8
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
+ }
9
15
  async connect() {
10
16
  if (!this.config.url) {
11
17
  throw new Error("SSE transport requires URL");
12
18
  }
13
19
  warnIfNonTlsRemoteUrl(this.config.url, this.logger);
14
- // Resolve headers once and cache for all subsequent requests
15
- this.resolvedHeaders = resolveServerHeaders(this.config);
20
+ await this.refreshResolvedHeaders();
16
21
  if (this.sseAbortController) {
17
22
  this.sseAbortController.abort();
18
23
  }
@@ -24,7 +29,7 @@ export class SseTransport extends BaseTransport {
24
29
  });
25
30
  // Fire and forget the stream reader
26
31
  this.startEventStream().catch((error) => {
27
- if (error instanceof Error && error.name !== 'AbortError') {
32
+ if (error instanceof Error && error.name !== "AbortError") {
28
33
  this.logger.error("[mcp-bridge] SSE stream error:", error.message);
29
34
  this.scheduleReconnect();
30
35
  }
@@ -34,16 +39,58 @@ export class SseTransport extends BaseTransport {
34
39
  this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
35
40
  }
36
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
+ }
37
84
  async startEventStream() {
38
85
  if (!this.config.url)
39
86
  return;
40
- const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
41
- const headers = { ...base, "Accept": "text/event-stream" };
87
+ const base = await this.getBaseHeaders();
88
+ const headers = { ...base, Accept: "text/event-stream" };
42
89
  try {
43
- const response = await fetch(this.config.url, {
90
+ const response = await this.fetchWithOAuthRetry(this.config.url, {
44
91
  method: "GET",
45
92
  headers,
46
- signal: this.sseAbortController?.signal
93
+ signal: this.sseAbortController?.signal,
47
94
  });
48
95
  if (!response.ok) {
49
96
  throw new Error(`SSE connection failed: HTTP ${response.status}`);
@@ -60,7 +107,7 @@ export class SseTransport extends BaseTransport {
60
107
  if (done)
61
108
  break;
62
109
  buffer += decoder.decode(value, { stream: true });
63
- const lines = buffer.split('\n');
110
+ const lines = buffer.split("\n");
64
111
  buffer = lines.pop() || "";
65
112
  for (const line of lines) {
66
113
  this.processEventLine(line, state);
@@ -71,7 +118,7 @@ export class SseTransport extends BaseTransport {
71
118
  this.scheduleReconnect();
72
119
  }
73
120
  catch (error) {
74
- if (error instanceof Error && error.name === 'AbortError')
121
+ if (error instanceof Error && error.name === "AbortError")
75
122
  return;
76
123
  this.logger.error("SSE stream error:", error);
77
124
  this.scheduleReconnect();
@@ -131,12 +178,12 @@ export class SseTransport extends BaseTransport {
131
178
  if (!this.connected || !this.endpointUrl) {
132
179
  throw new Error("SSE transport not connected or no endpoint URL");
133
180
  }
134
- const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
181
+ const base = await this.getBaseHeaders();
135
182
  const headers = { ...base, "Content-Type": "application/json" };
136
- const response = await fetch(this.endpointUrl, {
183
+ const response = await this.fetchWithOAuthRetry(this.endpointUrl, {
137
184
  method: "POST",
138
185
  headers,
139
- body: JSON.stringify(notification)
186
+ body: JSON.stringify(notification),
140
187
  });
141
188
  if (!response.ok) {
142
189
  this.logger.warn(`[mcp-bridge] SSE notification got HTTP ${response.status}`);
@@ -157,33 +204,35 @@ export class SseTransport extends BaseTransport {
157
204
  reject(new Error(`Request timeout after ${requestTimeout}ms`));
158
205
  }, requestTimeout);
159
206
  this.pendingRequests.set(id, { resolve, reject, timeout });
160
- const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
161
- const headers = { ...base, "Content-Type": "application/json" };
162
207
  const abortController = new AbortController();
163
208
  this.pendingRequestControllers.set(id, abortController);
164
209
  // The response arrives via the SSE stream (handleMessage), not from this fetch.
165
210
  // The fetch only confirms the server accepted the request (HTTP 200).
166
211
  // If the fetch fails, we reject immediately; otherwise we wait for the SSE stream.
167
- fetch(this.endpointUrl, {
168
- method: "POST",
169
- headers,
170
- body: JSON.stringify(requestWithId),
171
- signal: abortController.signal
172
- })
173
- .then((response) => {
174
- this.pendingRequestControllers.delete(id);
175
- 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);
176
231
  clearTimeout(timeout);
177
232
  this.pendingRequests.delete(id);
178
- reject(new Error(`HTTP ${response.status}`));
233
+ reject(error);
179
234
  }
180
- })
181
- .catch((error) => {
182
- this.pendingRequestControllers.delete(id);
183
- clearTimeout(timeout);
184
- this.pendingRequests.delete(id);
185
- reject(error);
186
- });
235
+ })();
187
236
  });
188
237
  }
189
238
  isSameOrigin(url) {
@@ -1,11 +1,18 @@
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?;
5
6
  private resolvedHeaders;
6
7
  private pendingRequestControllers;
8
+ private readonly tokenManager;
7
9
  protected get transportName(): string;
10
+ constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager);
8
11
  connect(): Promise<void>;
12
+ private getBaseHeaders;
13
+ private refreshResolvedHeaders;
14
+ private invalidateOAuth2Token;
15
+ private fetchWithOAuthRetry;
9
16
  sendRequest(request: McpRequest): Promise<McpResponse>;
10
17
  sendNotification(notification: any): Promise<void>;
11
18
  private probeServer;
@@ -1,22 +1,69 @@
1
1
  import { nextRequestId } from "./types.js";
2
- import { BaseTransport, resolveServerHeaders, 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;
5
6
  resolvedHeaders = null;
6
7
  pendingRequestControllers = new Map();
8
+ tokenManager;
7
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
+ }
8
14
  async connect() {
9
15
  if (!this.config.url) {
10
16
  throw new Error("Streamable HTTP transport requires URL");
11
17
  }
12
18
  warnIfNonTlsRemoteUrl(this.config.url, this.logger);
13
- // Validate that all header/auth env vars resolve (fail fast)
14
- this.resolvedHeaders = resolveServerHeaders(this.config);
19
+ await this.refreshResolvedHeaders();
15
20
  await this.probeServer();
16
21
  this.connected = true;
17
22
  this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
18
23
  this.logger.info(`[mcp-bridge] Streamable HTTP transport ready for ${this.config.url}`);
19
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
+ }
20
67
  async sendRequest(request) {
21
68
  if (!this.connected || !this.config.url) {
22
69
  throw new Error("Streamable HTTP transport not connected");
@@ -34,108 +81,111 @@ export class StreamableHttpTransport extends BaseTransport {
34
81
  }, requestTimeout);
35
82
  this.pendingRequests.set(id, { resolve, reject, timeout });
36
83
  this.pendingRequestControllers.set(id, abortController);
37
- const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
38
- const headers = {
39
- ...base,
40
- "Accept": "application/json, text/event-stream",
41
- "Content-Type": "application/json"
42
- };
43
- if (this.sessionId) {
44
- headers["mcp-session-id"] = this.sessionId;
45
- }
46
- fetch(this.config.url, {
47
- method: "POST",
48
- headers,
49
- body: JSON.stringify(requestWithId),
50
- signal: abortController.signal
51
- })
52
- .then(async (response) => {
53
- this.pendingRequestControllers.delete(id);
54
- const responseSessionId = response.headers.get("mcp-session-id");
55
- if (responseSessionId) {
56
- this.sessionId = responseSessionId;
57
- }
58
- if (!response.ok) {
59
- clearTimeout(timeout);
60
- this.pendingRequests.delete(id);
61
- reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
62
- return;
63
- }
84
+ (async () => {
64
85
  try {
65
- const contentType = response.headers.get("content-type") || "";
66
- let jsonResponse;
67
- if (contentType.includes("text/event-stream")) {
68
- const text = await response.text();
69
- const lines = text.split('\n');
70
- // SSE event boundary parsing: collect data lines, dispatch on empty line
71
- let dataBuffer = [];
72
- const dispatch = () => {
73
- if (dataBuffer.length === 0)
74
- return;
75
- const data = dataBuffer.join("\n");
76
- dataBuffer = [];
77
- try {
78
- this.handleMessage(JSON.parse(data));
79
- }
80
- catch { /* skip malformed events */ }
81
- };
82
- let hasData = false;
83
- for (const line of lines) {
84
- const trimmed = line.trim();
85
- if (trimmed.startsWith("data:")) {
86
- dataBuffer.push(trimmed.substring(5).trimStart());
87
- 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
+ }
88
141
  }
89
- else if (trimmed === "" && dataBuffer.length > 0) {
90
- 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");
91
146
  }
92
147
  }
93
- // Dispatch any trailing data (server may omit final empty line)
94
- dispatch();
95
- if (!hasData) {
96
- throw new Error("No data lines in SSE response");
148
+ else {
149
+ this.handleMessage(await response.json());
97
150
  }
98
151
  }
99
- else {
100
- 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))));
101
156
  }
102
157
  }
103
158
  catch (error) {
159
+ this.pendingRequestControllers.delete(id);
104
160
  clearTimeout(timeout);
105
161
  this.pendingRequests.delete(id);
106
- reject(new Error("Failed to parse response: " + (error instanceof Error ? error.message : String(error))));
107
- }
108
- })
109
- .catch(error => {
110
- this.pendingRequestControllers.delete(id);
111
- clearTimeout(timeout);
112
- this.pendingRequests.delete(id);
113
- if (error.name === 'TypeError' && error.message.includes('fetch')) {
114
- this.logger.error("Connection error, scheduling reconnect:", error.message);
115
- 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);
116
167
  }
117
- reject(error);
118
- });
168
+ })();
119
169
  });
120
170
  }
121
171
  async sendNotification(notification) {
122
172
  if (!this.connected || !this.config.url) {
123
173
  throw new Error("Streamable HTTP transport not connected");
124
174
  }
125
- const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
175
+ const base = await this.getBaseHeaders();
126
176
  const headers = {
127
177
  ...base,
128
- "Accept": "application/json, text/event-stream",
129
- "Content-Type": "application/json"
178
+ Accept: "application/json, text/event-stream",
179
+ "Content-Type": "application/json",
130
180
  };
131
181
  if (this.sessionId) {
132
182
  headers["mcp-session-id"] = this.sessionId;
133
183
  }
134
184
  try {
135
- const response = await fetch(this.config.url, {
185
+ const response = await this.fetchWithOAuthRetry(this.config.url, {
136
186
  method: "POST",
137
187
  headers,
138
- body: JSON.stringify(notification)
188
+ body: JSON.stringify(notification),
139
189
  });
140
190
  const responseSessionId = response.headers.get("mcp-session-id");
141
191
  if (responseSessionId) {
@@ -146,7 +196,7 @@ export class StreamableHttpTransport extends BaseTransport {
146
196
  }
147
197
  }
148
198
  catch (error) {
149
- if (error instanceof Error && error.name === 'TypeError' && error.message.includes('fetch')) {
199
+ if (error instanceof Error && error.name === "TypeError" && error.message.includes("fetch")) {
150
200
  this.logger.error("Connection error during notification, scheduling reconnect:", error.message);
151
201
  this.scheduleReconnect();
152
202
  }
@@ -157,11 +207,11 @@ export class StreamableHttpTransport extends BaseTransport {
157
207
  if (!this.config.url)
158
208
  return;
159
209
  try {
160
- const headers = this.resolvedHeaders ?? resolveServerHeaders(this.config);
161
- const optionsResponse = await fetch(this.config.url, { method: "OPTIONS", headers });
210
+ const headers = await this.getBaseHeaders();
211
+ const optionsResponse = await this.fetchWithOAuthRetry(this.config.url, { method: "OPTIONS", headers });
162
212
  if (optionsResponse.ok)
163
213
  return;
164
- const headResponse = await fetch(this.config.url, { method: "HEAD", headers });
214
+ const headResponse = await this.fetchWithOAuthRetry(this.config.url, { method: "HEAD", headers });
165
215
  if (!headResponse.ok) {
166
216
  this.logger.warn(`[mcp-bridge] Streamable HTTP server probe: OPTIONS ${optionsResponse.status}, HEAD ${headResponse.status} (non-blocking, connection continues)`);
167
217
  }
@@ -180,11 +230,11 @@ export class StreamableHttpTransport extends BaseTransport {
180
230
  // Send DELETE request if we have a session to clean up
181
231
  if (this.sessionId && this.config.url) {
182
232
  try {
183
- const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
233
+ const base = await this.getBaseHeaders();
184
234
  const headers = { ...base, "mcp-session-id": this.sessionId };
185
- await fetch(this.config.url, {
235
+ await this.fetchWithOAuthRetry(this.config.url, {
186
236
  method: "DELETE",
187
- headers
237
+ headers,
188
238
  });
189
239
  this.sessionId = undefined;
190
240
  this.logger.info("Streamable HTTP session cleaned up");
@@ -10,6 +10,13 @@ export type HttpAuthConfig = {
10
10
  } | {
11
11
  type: "header";
12
12
  headers: Record<string, string>;
13
+ } | {
14
+ type: "oauth2";
15
+ clientId: string;
16
+ clientSecret: string;
17
+ tokenUrl: string;
18
+ scopes?: string[];
19
+ audience?: string;
13
20
  };
14
21
  export interface RetryConfig {
15
22
  maxAttempts?: number;
@@ -130,9 +137,6 @@ export interface McpServerConnection {
130
137
  /** Bridge-level config loaded from ~/.mcp-bridge/config.json */
131
138
  export interface BridgeConfig extends McpClientConfig {
132
139
  http?: {
133
- auth?: {
134
- type: "bearer";
135
- token: string;
136
- };
140
+ auth?: HttpAuthConfig;
137
141
  };
138
142
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "2.0.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",