@aiwerk/mcp-bridge 2.5.2 → 2.5.3

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.
@@ -4,8 +4,11 @@ export class OAuth2TokenManager {
4
4
  logger;
5
5
  tokenCache = new Map();
6
6
  inflight = new Map();
7
- constructor(logger) {
7
+ authCodeInflight = new Map();
8
+ tokenStore;
9
+ constructor(logger, tokenStore) {
8
10
  this.logger = logger;
11
+ this.tokenStore = tokenStore;
9
12
  }
10
13
  async getToken(config) {
11
14
  const key = this.makeKey(config.tokenUrl, config.clientId);
@@ -38,6 +41,92 @@ export class OAuth2TokenManager {
38
41
  this.tokenCache.clear();
39
42
  this.inflight.clear();
40
43
  }
44
+ /**
45
+ * Get a token for an authorization_code flow server.
46
+ * Checks TokenStore, refreshes if expired, throws if unavailable.
47
+ */
48
+ async getTokenForAuthCode(serverName, config) {
49
+ if (!this.tokenStore) {
50
+ throw new Error(`Authentication required for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
51
+ }
52
+ const stored = this.tokenStore.load(serverName);
53
+ if (!stored) {
54
+ const err = new Error(`Authentication required for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
55
+ err.code = -32007;
56
+ throw err;
57
+ }
58
+ const now = Date.now();
59
+ if (stored.expiresAt > now) {
60
+ return stored.accessToken;
61
+ }
62
+ // Token expired — try refresh with inflight dedup to avoid
63
+ // concurrent requests both trying to refresh the same token
64
+ // (the second refresh would fail because the first invalidated the refresh_token)
65
+ const existingInflight = this.authCodeInflight.get(serverName);
66
+ if (existingInflight) {
67
+ return existingInflight;
68
+ }
69
+ const refreshPromise = this.doAuthCodeRefresh(serverName, stored, config);
70
+ this.authCodeInflight.set(serverName, refreshPromise);
71
+ try {
72
+ return await refreshPromise;
73
+ }
74
+ finally {
75
+ this.authCodeInflight.delete(serverName);
76
+ }
77
+ }
78
+ async doAuthCodeRefresh(serverName, stored, config) {
79
+ if (stored.refreshToken) {
80
+ try {
81
+ const refreshed = await this.refreshAuthCodeToken(stored, config);
82
+ this.tokenStore.save(serverName, refreshed);
83
+ return refreshed.accessToken;
84
+ }
85
+ catch (err) {
86
+ this.logger.warn("[mcp-bridge] Auth code token refresh failed:", err);
87
+ }
88
+ }
89
+ // Refresh failed or no refresh token
90
+ this.tokenStore.remove(serverName);
91
+ const error = new Error(`Authentication expired for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
92
+ error.code = -32006;
93
+ throw error;
94
+ }
95
+ async refreshAuthCodeToken(stored, config) {
96
+ const formData = new URLSearchParams();
97
+ formData.set("grant_type", "refresh_token");
98
+ formData.set("refresh_token", stored.refreshToken);
99
+ if (config.clientId)
100
+ formData.set("client_id", config.clientId);
101
+ if (config.clientSecret)
102
+ formData.set("client_secret", config.clientSecret);
103
+ if (config.scopes?.length)
104
+ formData.set("scope", config.scopes.join(" "));
105
+ const response = await fetch(stored.tokenUrl, {
106
+ method: "POST",
107
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
108
+ body: formData.toString(),
109
+ });
110
+ if (!response.ok) {
111
+ throw new Error(`OAuth2 refresh token exchange failed: HTTP ${response.status}`);
112
+ }
113
+ const payload = (await response.json());
114
+ if (!payload.access_token) {
115
+ throw new Error("OAuth2 refresh response missing access_token");
116
+ }
117
+ const expiresIn = Number.isFinite(payload.expires_in)
118
+ ? Number(payload.expires_in)
119
+ : DEFAULT_EXPIRES_IN_SECONDS;
120
+ const expiresAt = Date.now() + Math.max(0, expiresIn - EXPIRY_BUFFER_SECONDS) * 1000;
121
+ return {
122
+ accessToken: payload.access_token,
123
+ refreshToken: payload.refresh_token ?? stored.refreshToken,
124
+ expiresAt,
125
+ tokenUrl: stored.tokenUrl,
126
+ clientId: config.clientId,
127
+ scopes: config.scopes,
128
+ };
129
+ }
41
130
  makeKey(tokenUrl, clientId) {
42
131
  return `${tokenUrl}::${clientId}`;
43
132
  }
@@ -16,6 +16,10 @@ export declare function isToolAllowed(toolName: string, serverConfig: McpServerC
16
16
  /**
17
17
  * Apply max result size truncation.
18
18
  * Returns the result as-is or a truncation wrapper.
19
+ *
20
+ * Uses JSON-aware truncation: tries to produce valid JSON by truncating
21
+ * at the object/array level rather than slicing raw JSON strings
22
+ * (which produces invalid JSON that LLMs may hallucinate around).
19
23
  */
20
24
  export declare function applyMaxResultSize(result: any, serverConfig: McpServerConfig, clientConfig: McpClientConfig): any;
21
25
  /**
@@ -87,6 +87,10 @@ export function isToolAllowed(toolName, serverConfig) {
87
87
  /**
88
88
  * Apply max result size truncation.
89
89
  * Returns the result as-is or a truncation wrapper.
90
+ *
91
+ * Uses JSON-aware truncation: tries to produce valid JSON by truncating
92
+ * at the object/array level rather than slicing raw JSON strings
93
+ * (which produces invalid JSON that LLMs may hallucinate around).
90
94
  */
91
95
  export function applyMaxResultSize(result, serverConfig, clientConfig) {
92
96
  const limit = serverConfig.maxResultChars ?? clientConfig.maxResultChars;
@@ -95,12 +99,95 @@ export function applyMaxResultSize(result, serverConfig, clientConfig) {
95
99
  const serialized = JSON.stringify(result);
96
100
  if (serialized.length <= limit)
97
101
  return result;
102
+ // Try JSON-aware truncation: if the result is an array, take fewer elements;
103
+ // if it's an object with a nested array, truncate the largest array.
104
+ const truncated = truncateJsonAware(result, limit);
105
+ if (truncated !== null) {
106
+ return {
107
+ _truncated: true,
108
+ _originalLength: serialized.length,
109
+ result: truncated,
110
+ };
111
+ }
112
+ // Fallback: stringify and cut at a safe boundary, then wrap as a string
113
+ // to ensure the consumer always gets valid JSON
114
+ let cutPoint = Math.min(limit, serialized.length);
115
+ // Try to cut at the last complete JSON token boundary (comma, closing bracket, or newline)
116
+ const lastSafe = Math.max(serialized.lastIndexOf(",", cutPoint), serialized.lastIndexOf("}", cutPoint), serialized.lastIndexOf("]", cutPoint), serialized.lastIndexOf("\n", cutPoint));
117
+ if (lastSafe > cutPoint * 0.5) {
118
+ cutPoint = lastSafe + 1;
119
+ }
98
120
  return {
99
121
  _truncated: true,
100
122
  _originalLength: serialized.length,
101
- result: serialized.slice(0, limit),
123
+ result: serialized.slice(0, cutPoint) + "…",
124
+ _note: "Result truncated. Original response exceeded size limit.",
102
125
  };
103
126
  }
127
+ /**
128
+ * JSON-aware truncation: reduce array sizes to fit within the char limit.
129
+ * Returns the truncated value or null if not applicable.
130
+ */
131
+ function truncateJsonAware(value, limit) {
132
+ if (Array.isArray(value)) {
133
+ return truncateArray(value, limit);
134
+ }
135
+ if (value !== null && typeof value === "object") {
136
+ // Find the largest array field and truncate it
137
+ let largestKey = null;
138
+ let largestLen = 0;
139
+ for (const [k, v] of Object.entries(value)) {
140
+ if (Array.isArray(v) && v.length > largestLen) {
141
+ largestKey = k;
142
+ largestLen = v.length;
143
+ }
144
+ }
145
+ if (largestKey && largestLen > 1) {
146
+ const copy = { ...value };
147
+ copy[largestKey] = truncateArray(value[largestKey], limit);
148
+ if (JSON.stringify(copy).length <= limit) {
149
+ return copy;
150
+ }
151
+ // Still too large — try with fewer elements
152
+ return truncateObjectWithArrays(value, limit);
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+ function truncateArray(arr, limit) {
158
+ // Binary search for the number of elements that fit
159
+ let lo = 0;
160
+ let hi = arr.length;
161
+ while (lo < hi) {
162
+ const mid = (lo + hi + 1) >>> 1;
163
+ const slice = arr.slice(0, mid);
164
+ if (JSON.stringify(slice).length <= limit) {
165
+ lo = mid;
166
+ }
167
+ else {
168
+ hi = mid - 1;
169
+ }
170
+ }
171
+ return arr.slice(0, Math.max(1, lo));
172
+ }
173
+ function truncateObjectWithArrays(obj, limit) {
174
+ const copy = { ...obj };
175
+ // Progressively halve all arrays until it fits
176
+ for (let attempt = 0; attempt < 10; attempt++) {
177
+ let changed = false;
178
+ for (const [k, v] of Object.entries(copy)) {
179
+ if (Array.isArray(v) && v.length > 1) {
180
+ copy[k] = v.slice(0, Math.max(1, Math.ceil(v.length / 2)));
181
+ changed = true;
182
+ }
183
+ }
184
+ if (JSON.stringify(copy).length <= limit)
185
+ return copy;
186
+ if (!changed)
187
+ break;
188
+ }
189
+ return null;
190
+ }
104
191
  /**
105
192
  * Apply trust level wrapping/sanitization.
106
193
  */
@@ -6,6 +6,7 @@ import { SseTransport } from "./transport-sse.js";
6
6
  import { StdioTransport } from "./transport-stdio.js";
7
7
  import { StreamableHttpTransport } from "./transport-streamable-http.js";
8
8
  import { OAuth2TokenManager } from "./oauth2-token-manager.js";
9
+ import { FileTokenStore } from "./token-store.js";
9
10
  /**
10
11
  * Standalone MCP server that wraps the router.
11
12
  * Implements the MCP protocol (initialize, tools/list, tools/call)
@@ -25,7 +26,7 @@ export class StandaloneServer {
25
26
  constructor(config, logger) {
26
27
  this.config = config;
27
28
  this.logger = logger;
28
- this.tokenManager = new OAuth2TokenManager(logger);
29
+ this.tokenManager = new OAuth2TokenManager(logger, new FileTokenStore());
29
30
  if (this.isRouterMode()) {
30
31
  this.router = new McpRouter(config.servers || {}, config, logger);
31
32
  }
@@ -408,11 +409,11 @@ export class StandaloneServer {
408
409
  };
409
410
  switch (serverConfig.transport) {
410
411
  case "sse":
411
- return new SseTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId());
412
+ return new SseTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId(), serverName);
412
413
  case "stdio":
413
414
  return new StdioTransport(serverConfig, this.config, this.logger, onReconnected, () => this.nextRequestId());
414
415
  case "streamable-http":
415
- return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId());
416
+ return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId(), serverName);
416
417
  default:
417
418
  throw new Error(`Unsupported transport: ${serverConfig.transport}`);
418
419
  }
@@ -0,0 +1,30 @@
1
+ export interface StoredToken {
2
+ accessToken: string;
3
+ refreshToken?: string;
4
+ expiresAt: number;
5
+ tokenUrl: string;
6
+ clientId?: string;
7
+ scopes?: string[];
8
+ }
9
+ export interface TokenStore {
10
+ load(serverName: string): StoredToken | null;
11
+ save(serverName: string, token: StoredToken): void;
12
+ remove(serverName: string): void;
13
+ list(): {
14
+ serverName: string;
15
+ token: StoredToken;
16
+ }[];
17
+ }
18
+ export declare class FileTokenStore implements TokenStore {
19
+ private readonly tokensDir;
20
+ constructor(tokensDir?: string);
21
+ load(serverName: string): StoredToken | null;
22
+ save(serverName: string, token: StoredToken): void;
23
+ remove(serverName: string): void;
24
+ list(): {
25
+ serverName: string;
26
+ token: StoredToken;
27
+ }[];
28
+ private tokenPath;
29
+ private ensureDir;
30
+ }
@@ -0,0 +1,69 @@
1
+ import { readFileSync, writeFileSync, unlinkSync, readdirSync, existsSync, mkdirSync, chmodSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ const DEFAULT_TOKENS_DIR = join(homedir(), ".mcp-bridge", "tokens");
5
+ export class FileTokenStore {
6
+ tokensDir;
7
+ constructor(tokensDir) {
8
+ this.tokensDir = tokensDir ?? DEFAULT_TOKENS_DIR;
9
+ }
10
+ load(serverName) {
11
+ const filePath = this.tokenPath(serverName);
12
+ if (!existsSync(filePath))
13
+ return null;
14
+ try {
15
+ const raw = JSON.parse(readFileSync(filePath, "utf-8"));
16
+ if (!raw.accessToken || !raw.tokenUrl || typeof raw.expiresAt !== "number") {
17
+ return null;
18
+ }
19
+ return raw;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ save(serverName, token) {
26
+ this.ensureDir();
27
+ const filePath = this.tokenPath(serverName);
28
+ writeFileSync(filePath, JSON.stringify(token, null, 2) + "\n", "utf-8");
29
+ try {
30
+ chmodSync(filePath, 0o600);
31
+ }
32
+ catch { /* Windows doesn't support chmod */ }
33
+ }
34
+ remove(serverName) {
35
+ const filePath = this.tokenPath(serverName);
36
+ if (existsSync(filePath)) {
37
+ unlinkSync(filePath);
38
+ }
39
+ }
40
+ list() {
41
+ if (!existsSync(this.tokensDir))
42
+ return [];
43
+ const results = [];
44
+ for (const file of readdirSync(this.tokensDir)) {
45
+ if (!file.endsWith(".json"))
46
+ continue;
47
+ const serverName = file.slice(0, -5);
48
+ const token = this.load(serverName);
49
+ if (token) {
50
+ results.push({ serverName, token });
51
+ }
52
+ }
53
+ return results;
54
+ }
55
+ tokenPath(serverName) {
56
+ // Sanitize server name to prevent path traversal
57
+ const safe = serverName.replace(/[^a-zA-Z0-9_-]/g, "_");
58
+ return join(this.tokensDir, `${safe}.json`);
59
+ }
60
+ ensureDir() {
61
+ if (!existsSync(this.tokensDir)) {
62
+ mkdirSync(this.tokensDir, { recursive: true });
63
+ try {
64
+ chmodSync(this.tokensDir, 0o700);
65
+ }
66
+ catch { /* Windows */ }
67
+ }
68
+ }
69
+ }
@@ -1,5 +1,5 @@
1
1
  import { McpTransport, McpRequest, McpResponse, McpServerConfig, McpClientConfig, Logger, JsonRpcMessage, RequestIdGenerator } from "./types.js";
2
- import type { OAuth2Config, OAuth2TokenManager } from "./oauth2-token-manager.js";
2
+ import type { OAuth2Config, AuthCodeOAuth2Config, OAuth2TokenManager } from "./oauth2-token-manager.js";
3
3
  export type PendingRequest = {
4
4
  resolve: (value: McpResponse) => void;
5
5
  reject: (reason: Error) => void;
@@ -78,13 +78,19 @@ export declare function resolveArgs(args: string[], extraEnv?: Record<string, st
78
78
  * Resolve auth config into HTTP headers.
79
79
  */
80
80
  export declare function resolveAuthHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
81
+ /** Check whether an oauth2 auth config uses the authorization_code grant type. */
82
+ export declare function isAuthCodeOAuth2(auth: {
83
+ type: "oauth2";
84
+ grantType?: string;
85
+ }): boolean;
81
86
  export declare function resolveOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): OAuth2Config;
82
- export declare function resolveAuthHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Promise<Record<string, string>>;
87
+ export declare function resolveAuthCodeOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): AuthCodeOAuth2Config;
88
+ export declare function resolveAuthHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>, serverName?: string): Promise<Record<string, string>>;
83
89
  /**
84
90
  * Resolve server headers and merge auth headers (auth takes precedence).
85
91
  */
86
92
  export declare function resolveServerHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
87
- export declare function resolveServerHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Promise<Record<string, string>>;
93
+ export declare function resolveServerHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>, serverName?: string): Promise<Record<string, string>>;
88
94
  /**
89
95
  * Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
90
96
  */
@@ -181,25 +181,54 @@ 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
+ }
184
188
  export function resolveOAuth2Config(config, extraEnv, envFallback) {
185
189
  if (!config.auth || config.auth.type !== "oauth2") {
186
190
  throw new Error("[mcp-bridge] resolveOAuth2Config called for non-oauth2 auth config");
187
191
  }
192
+ if (isAuthCodeOAuth2(config.auth)) {
193
+ throw new Error("[mcp-bridge] resolveOAuth2Config called for authorization_code config — use resolveAuthCodeOAuth2Config instead");
194
+ }
188
195
  const scopes = config.auth.scopes?.map((scope, index) => resolveEnvVars(scope, `oauth2 scope[${index}]`, extraEnv, envFallback));
189
196
  return {
190
197
  clientId: resolveEnvVars(config.auth.clientId, "oauth2 clientId", extraEnv, envFallback),
191
198
  clientSecret: resolveEnvVars(config.auth.clientSecret, "oauth2 clientSecret", extraEnv, envFallback),
192
199
  tokenUrl: resolveEnvVars(config.auth.tokenUrl, "oauth2 tokenUrl", extraEnv, envFallback),
193
200
  ...(scopes && scopes.length > 0 ? { scopes } : {}),
194
- ...(config.auth.audience
201
+ ...("audience" in config.auth && config.auth.audience
195
202
  ? { audience: resolveEnvVars(config.auth.audience, "oauth2 audience", extraEnv, envFallback) }
196
203
  : {}),
197
204
  };
198
205
  }
199
- export async function resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback) {
206
+ export function resolveAuthCodeOAuth2Config(config, extraEnv, envFallback) {
207
+ if (!config.auth || config.auth.type !== "oauth2" || !isAuthCodeOAuth2(config.auth)) {
208
+ throw new Error("[mcp-bridge] resolveAuthCodeOAuth2Config called for non-authorization_code auth config");
209
+ }
210
+ const auth = config.auth;
211
+ const scopes = auth.scopes?.map((scope, index) => resolveEnvVars(scope, `oauth2 scope[${index}]`, extraEnv, envFallback));
212
+ return {
213
+ grantType: "authorization_code",
214
+ tokenUrl: resolveEnvVars(auth.tokenUrl, "oauth2 tokenUrl", extraEnv, envFallback),
215
+ ...(auth.clientId ? { clientId: resolveEnvVars(auth.clientId, "oauth2 clientId", extraEnv, envFallback) } : {}),
216
+ ...(auth.clientSecret ? { clientSecret: resolveEnvVars(auth.clientSecret, "oauth2 clientSecret", extraEnv, envFallback) } : {}),
217
+ ...(scopes && scopes.length > 0 ? { scopes } : {}),
218
+ };
219
+ }
220
+ export async function resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback, serverName) {
200
221
  if (!config.auth)
201
222
  return {};
202
223
  if (config.auth.type === "oauth2") {
224
+ if (isAuthCodeOAuth2(config.auth)) {
225
+ if (!serverName) {
226
+ throw new Error("[mcp-bridge] serverName is required for authorization_code OAuth2 flow");
227
+ }
228
+ const authCodeConfig = resolveAuthCodeOAuth2Config(config, extraEnv, envFallback);
229
+ const token = await tokenManager.getTokenForAuthCode(serverName, authCodeConfig);
230
+ return { Authorization: `Bearer ${token}` };
231
+ }
203
232
  const oauth2Config = resolveOAuth2Config(config, extraEnv, envFallback);
204
233
  const token = await tokenManager.getToken(oauth2Config);
205
234
  return { Authorization: `Bearer ${token}` };
@@ -214,9 +243,9 @@ export function resolveServerHeaders(config, extraEnv, envFallback) {
214
243
  const auth = resolveAuthHeaders(config, extraEnv, envFallback);
215
244
  return { ...base, ...auth };
216
245
  }
217
- export async function resolveServerHeadersAsync(config, tokenManager, extraEnv, envFallback) {
246
+ export async function resolveServerHeadersAsync(config, tokenManager, extraEnv, envFallback, serverName) {
218
247
  const base = resolveEnvRecord(config.headers || {}, "header", extraEnv, envFallback);
219
- const auth = await resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback);
248
+ const auth = await resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback, serverName);
220
249
  return { ...base, ...auth };
221
250
  }
222
251
  /**
@@ -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,15 @@ 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;
20
29
  };
21
30
  export interface RetryConfig {
22
31
  maxAttempts?: number;