@aiwerk/mcp-bridge 2.5.2 → 2.6.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.
@@ -181,25 +181,83 @@ export function resolveAuthHeaders(config, extraEnv, envFallback) {
181
181
  }
182
182
  throw new Error("[mcp-bridge] OAuth2 auth requires async header resolution via resolveAuthHeadersAsync");
183
183
  }
184
+ /** Check whether an oauth2 auth config uses the authorization_code grant type. */
185
+ export function isAuthCodeOAuth2(auth) {
186
+ return auth.grantType === "authorization_code";
187
+ }
188
+ /** Check whether an oauth2 auth config uses the device_code grant type. */
189
+ export function isDeviceCodeOAuth2(auth) {
190
+ return auth.grantType === "device_code";
191
+ }
184
192
  export function resolveOAuth2Config(config, extraEnv, envFallback) {
185
193
  if (!config.auth || config.auth.type !== "oauth2") {
186
194
  throw new Error("[mcp-bridge] resolveOAuth2Config called for non-oauth2 auth config");
187
195
  }
188
- const scopes = config.auth.scopes?.map((scope, index) => resolveEnvVars(scope, `oauth2 scope[${index}]`, extraEnv, envFallback));
196
+ if (isAuthCodeOAuth2(config.auth)) {
197
+ throw new Error("[mcp-bridge] resolveOAuth2Config called for authorization_code config — use resolveAuthCodeOAuth2Config instead");
198
+ }
199
+ if (isDeviceCodeOAuth2(config.auth)) {
200
+ throw new Error("[mcp-bridge] resolveOAuth2Config called for device_code config — use resolveDeviceCodeOAuth2Config instead");
201
+ }
202
+ const auth = config.auth;
203
+ const scopes = auth.scopes?.map((scope, index) => resolveEnvVars(scope, `oauth2 scope[${index}]`, extraEnv, envFallback));
189
204
  return {
190
- clientId: resolveEnvVars(config.auth.clientId, "oauth2 clientId", extraEnv, envFallback),
191
- clientSecret: resolveEnvVars(config.auth.clientSecret, "oauth2 clientSecret", extraEnv, envFallback),
192
- tokenUrl: resolveEnvVars(config.auth.tokenUrl, "oauth2 tokenUrl", extraEnv, envFallback),
205
+ clientId: resolveEnvVars(auth.clientId, "oauth2 clientId", extraEnv, envFallback),
206
+ clientSecret: resolveEnvVars(auth.clientSecret, "oauth2 clientSecret", extraEnv, envFallback),
207
+ tokenUrl: resolveEnvVars(auth.tokenUrl, "oauth2 tokenUrl", extraEnv, envFallback),
193
208
  ...(scopes && scopes.length > 0 ? { scopes } : {}),
194
- ...(config.auth.audience
195
- ? { audience: resolveEnvVars(config.auth.audience, "oauth2 audience", extraEnv, envFallback) }
209
+ ...(auth.audience
210
+ ? { audience: resolveEnvVars(auth.audience, "oauth2 audience", extraEnv, envFallback) }
196
211
  : {}),
197
212
  };
198
213
  }
199
- export async function resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback) {
214
+ export function resolveAuthCodeOAuth2Config(config, extraEnv, envFallback) {
215
+ if (!config.auth || config.auth.type !== "oauth2" || !isAuthCodeOAuth2(config.auth)) {
216
+ throw new Error("[mcp-bridge] resolveAuthCodeOAuth2Config called for non-authorization_code auth config");
217
+ }
218
+ const auth = config.auth;
219
+ const scopes = auth.scopes?.map((scope, index) => resolveEnvVars(scope, `oauth2 scope[${index}]`, extraEnv, envFallback));
220
+ return {
221
+ grantType: "authorization_code",
222
+ tokenUrl: resolveEnvVars(auth.tokenUrl, "oauth2 tokenUrl", extraEnv, envFallback),
223
+ ...(auth.clientId ? { clientId: resolveEnvVars(auth.clientId, "oauth2 clientId", extraEnv, envFallback) } : {}),
224
+ ...(auth.clientSecret ? { clientSecret: resolveEnvVars(auth.clientSecret, "oauth2 clientSecret", extraEnv, envFallback) } : {}),
225
+ ...(scopes && scopes.length > 0 ? { scopes } : {}),
226
+ };
227
+ }
228
+ export function resolveDeviceCodeOAuth2Config(config, extraEnv, envFallback) {
229
+ if (!config.auth || config.auth.type !== "oauth2" || !isDeviceCodeOAuth2(config.auth)) {
230
+ throw new Error("[mcp-bridge] resolveDeviceCodeOAuth2Config called for non-device_code auth config");
231
+ }
232
+ const auth = config.auth;
233
+ const scopes = auth.scopes?.map((scope, index) => resolveEnvVars(scope, `oauth2 scope[${index}]`, extraEnv, envFallback));
234
+ return {
235
+ grantType: "device_code",
236
+ tokenUrl: resolveEnvVars(auth.tokenUrl, "oauth2 tokenUrl", extraEnv, envFallback),
237
+ clientId: resolveEnvVars(auth.clientId, "oauth2 clientId", extraEnv, envFallback),
238
+ ...(scopes && scopes.length > 0 ? { scopes } : {}),
239
+ };
240
+ }
241
+ export async function resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback, serverName) {
200
242
  if (!config.auth)
201
243
  return {};
202
244
  if (config.auth.type === "oauth2") {
245
+ if (isAuthCodeOAuth2(config.auth)) {
246
+ if (!serverName) {
247
+ throw new Error("[mcp-bridge] serverName is required for authorization_code OAuth2 flow");
248
+ }
249
+ const authCodeConfig = resolveAuthCodeOAuth2Config(config, extraEnv, envFallback);
250
+ const token = await tokenManager.getTokenForAuthCode(serverName, authCodeConfig);
251
+ return { Authorization: `Bearer ${token}` };
252
+ }
253
+ if (isDeviceCodeOAuth2(config.auth)) {
254
+ if (!serverName) {
255
+ throw new Error("[mcp-bridge] serverName is required for device_code OAuth2 flow");
256
+ }
257
+ const deviceCodeConfig = resolveDeviceCodeOAuth2Config(config, extraEnv, envFallback);
258
+ const token = await tokenManager.getTokenForDeviceCode(serverName, deviceCodeConfig);
259
+ return { Authorization: `Bearer ${token}` };
260
+ }
203
261
  const oauth2Config = resolveOAuth2Config(config, extraEnv, envFallback);
204
262
  const token = await tokenManager.getToken(oauth2Config);
205
263
  return { Authorization: `Bearer ${token}` };
@@ -214,9 +272,9 @@ export function resolveServerHeaders(config, extraEnv, envFallback) {
214
272
  const auth = resolveAuthHeaders(config, extraEnv, envFallback);
215
273
  return { ...base, ...auth };
216
274
  }
217
- export async function resolveServerHeadersAsync(config, tokenManager, extraEnv, envFallback) {
275
+ export async function resolveServerHeadersAsync(config, tokenManager, extraEnv, envFallback, serverName) {
218
276
  const base = resolveEnvRecord(config.headers || {}, "header", extraEnv, envFallback);
219
- const auth = await resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback);
277
+ const auth = await resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback, serverName);
220
278
  return { ...base, ...auth };
221
279
  }
222
280
  /**
@@ -7,8 +7,9 @@ export declare class SseTransport extends BaseTransport {
7
7
  private resolvedHeaders;
8
8
  private pendingRequestControllers;
9
9
  private readonly tokenManager;
10
+ private readonly serverName?;
10
11
  protected get transportName(): string;
11
- constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: RequestIdGenerator);
12
+ constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: RequestIdGenerator, serverName?: string);
12
13
  connect(): Promise<void>;
13
14
  private _onEndpointReceived;
14
15
  private getBaseHeaders;
@@ -1,15 +1,17 @@
1
1
  import { OAuth2TokenManager } from "./oauth2-token-manager.js";
2
- import { BaseTransport, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
2
+ import { BaseTransport, isAuthCodeOAuth2, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
3
3
  export class SseTransport extends BaseTransport {
4
4
  endpointUrl = null;
5
5
  sseAbortController = null;
6
6
  resolvedHeaders = null;
7
7
  pendingRequestControllers = new Map();
8
8
  tokenManager;
9
+ serverName;
9
10
  get transportName() { return "SSE"; }
10
- constructor(config, clientConfig, logger, onReconnected, tokenManager, requestIdGenerator) {
11
+ constructor(config, clientConfig, logger, onReconnected, tokenManager, requestIdGenerator, serverName) {
11
12
  super(config, clientConfig, logger, onReconnected, requestIdGenerator);
12
13
  this.tokenManager = tokenManager ?? new OAuth2TokenManager(logger);
14
+ this.serverName = serverName;
13
15
  }
14
16
  async connect() {
15
17
  if (!this.config.url) {
@@ -52,7 +54,7 @@ export class SseTransport extends BaseTransport {
52
54
  }
53
55
  async refreshResolvedHeaders() {
54
56
  if (this.config.auth?.type === "oauth2") {
55
- this.resolvedHeaders = await resolveServerHeadersAsync(this.config, this.tokenManager, undefined, this.clientConfig.envFallback);
57
+ this.resolvedHeaders = await resolveServerHeadersAsync(this.config, this.tokenManager, undefined, this.clientConfig.envFallback, this.serverName);
56
58
  }
57
59
  else {
58
60
  this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
@@ -63,6 +65,9 @@ export class SseTransport extends BaseTransport {
63
65
  if (this.config.auth?.type !== "oauth2") {
64
66
  return;
65
67
  }
68
+ if (isAuthCodeOAuth2(this.config.auth)) {
69
+ return;
70
+ }
66
71
  const oauth2Config = resolveOAuth2Config(this.config, undefined, this.clientConfig.envFallback);
67
72
  this.tokenManager.invalidate(oauth2Config.tokenUrl, oauth2Config.clientId);
68
73
  }
@@ -63,7 +63,7 @@ export class StdioTransport extends BaseTransport {
63
63
  this.process.stdout.on("data", (data) => {
64
64
  this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, data]);
65
65
  // Safety limit: prevent unbounded buffer growth from misbehaving servers
66
- const MAX_BUFFER = 50 * 1024 * 1024; // 50MB
66
+ const MAX_BUFFER = 10 * 1024 * 1024; // 10MB
67
67
  if (this.stdoutBuffer.length > MAX_BUFFER) {
68
68
  this.logger.error(`[mcp-bridge] Stdio buffer exceeded ${MAX_BUFFER} bytes, killing process`);
69
69
  this.process?.kill();
@@ -6,8 +6,9 @@ export declare class StreamableHttpTransport extends BaseTransport {
6
6
  private resolvedHeaders;
7
7
  private pendingRequestControllers;
8
8
  private readonly tokenManager;
9
+ private readonly serverName?;
9
10
  protected get transportName(): string;
10
- constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: RequestIdGenerator);
11
+ constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: RequestIdGenerator, serverName?: string);
11
12
  connect(): Promise<void>;
12
13
  private getBaseHeaders;
13
14
  private refreshResolvedHeaders;
@@ -1,14 +1,16 @@
1
1
  import { OAuth2TokenManager } from "./oauth2-token-manager.js";
2
- import { BaseTransport, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
2
+ import { BaseTransport, isAuthCodeOAuth2, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
3
3
  export class StreamableHttpTransport extends BaseTransport {
4
4
  sessionId;
5
5
  resolvedHeaders = null;
6
6
  pendingRequestControllers = new Map();
7
7
  tokenManager;
8
+ serverName;
8
9
  get transportName() { return "streamable-http"; }
9
- constructor(config, clientConfig, logger, onReconnected, tokenManager, requestIdGenerator) {
10
+ constructor(config, clientConfig, logger, onReconnected, tokenManager, requestIdGenerator, serverName) {
10
11
  super(config, clientConfig, logger, onReconnected, requestIdGenerator);
11
12
  this.tokenManager = tokenManager ?? new OAuth2TokenManager(logger);
13
+ this.serverName = serverName;
12
14
  }
13
15
  async connect() {
14
16
  if (!this.config.url) {
@@ -35,7 +37,7 @@ export class StreamableHttpTransport extends BaseTransport {
35
37
  }
36
38
  async refreshResolvedHeaders() {
37
39
  if (this.config.auth?.type === "oauth2") {
38
- this.resolvedHeaders = await resolveServerHeadersAsync(this.config, this.tokenManager, undefined, this.clientConfig.envFallback);
40
+ this.resolvedHeaders = await resolveServerHeadersAsync(this.config, this.tokenManager, undefined, this.clientConfig.envFallback, this.serverName);
39
41
  }
40
42
  else {
41
43
  this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
@@ -46,6 +48,10 @@ export class StreamableHttpTransport extends BaseTransport {
46
48
  if (this.config.auth?.type !== "oauth2") {
47
49
  return;
48
50
  }
51
+ // authorization_code tokens are managed via TokenStore, not the in-memory cache
52
+ if (isAuthCodeOAuth2(this.config.auth)) {
53
+ return;
54
+ }
49
55
  const oauth2Config = resolveOAuth2Config(this.config, undefined, this.clientConfig.envFallback);
50
56
  this.tokenManager.invalidate(oauth2Config.tokenUrl, oauth2Config.clientId);
51
57
  }
@@ -111,10 +117,17 @@ export class StreamableHttpTransport extends BaseTransport {
111
117
  try {
112
118
  const contentType = response.headers.get("content-type") || "";
113
119
  if (contentType.includes("text/event-stream")) {
114
- const text = await response.text();
115
- const lines = text.split("\n");
116
- // SSE event boundary parsing: collect data lines, dispatch on empty line
120
+ // Stream SSE response incrementally using ReadableStream
121
+ // (previous implementation used response.text() which blocked until
122
+ // the entire response was received, causing timeouts on long-running calls)
123
+ if (!response.body) {
124
+ throw new Error("SSE response has no body stream");
125
+ }
126
+ const reader = response.body.getReader();
127
+ const decoder = new TextDecoder();
128
+ let partial = "";
117
129
  let dataBuffer = [];
130
+ let hasData = false;
118
131
  const dispatch = () => {
119
132
  if (dataBuffer.length === 0)
120
133
  return;
@@ -127,19 +140,37 @@ export class StreamableHttpTransport extends BaseTransport {
127
140
  // skip malformed events
128
141
  }
129
142
  };
130
- let hasData = false;
131
- for (const line of lines) {
132
- const trimmed = line.trim();
133
- if (trimmed.startsWith("data:")) {
134
- dataBuffer.push(trimmed.substring(5).trimStart());
135
- hasData = true;
143
+ try {
144
+ while (true) {
145
+ const { done, value } = await reader.read();
146
+ if (done)
147
+ break;
148
+ partial += decoder.decode(value, { stream: true });
149
+ const lines = partial.split("\n");
150
+ // Keep the last (potentially incomplete) line in partial
151
+ partial = lines.pop() || "";
152
+ for (const line of lines) {
153
+ const trimmed = line.trim();
154
+ if (trimmed.startsWith("data:")) {
155
+ dataBuffer.push(trimmed.substring(5).trimStart());
156
+ hasData = true;
157
+ }
158
+ else if (trimmed === "" && dataBuffer.length > 0) {
159
+ dispatch();
160
+ }
161
+ }
136
162
  }
137
- else if (trimmed === "" && dataBuffer.length > 0) {
138
- dispatch();
163
+ // Process any remaining partial line
164
+ if (partial.trim().startsWith("data:")) {
165
+ dataBuffer.push(partial.trim().substring(5).trimStart());
166
+ hasData = true;
139
167
  }
168
+ // Dispatch any trailing data (server may omit final empty line)
169
+ dispatch();
170
+ }
171
+ finally {
172
+ reader.releaseLock();
140
173
  }
141
- // Dispatch any trailing data (server may omit final empty line)
142
- dispatch();
143
174
  if (!hasData) {
144
175
  throw new Error("No data lines in SSE response");
145
176
  }
@@ -17,6 +17,22 @@ export type HttpAuthConfig = {
17
17
  tokenUrl: string;
18
18
  scopes?: string[];
19
19
  audience?: string;
20
+ } | {
21
+ type: "oauth2";
22
+ grantType: "authorization_code";
23
+ authorizationUrl: string;
24
+ tokenUrl: string;
25
+ clientId?: string;
26
+ clientSecret?: string;
27
+ scopes?: string[];
28
+ callbackPort?: number;
29
+ } | {
30
+ type: "oauth2";
31
+ grantType: "device_code";
32
+ deviceAuthorizationUrl: string;
33
+ tokenUrl: string;
34
+ clientId: string;
35
+ scopes?: string[];
20
36
  };
21
37
  export interface RetryConfig {
22
38
  maxAttempts?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "2.5.2",
3
+ "version": "2.6.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",
@@ -44,7 +44,7 @@
44
44
  "build": "tsc",
45
45
  "test": "node --import tsx --test tests/*.test.ts",
46
46
  "typecheck": "tsc --noEmit",
47
- "prepublishOnly": "bash scripts/validate-recipes.sh",
47
+ "prepublishOnly": "tsc && bash scripts/validate-recipes.sh",
48
48
  "validate-recipe": "npx tsx bin/validate-recipe.ts",
49
49
  "lint": "eslint src/",
50
50
  "format": "prettier --write src/",