@aiwerk/mcp-bridge 2.5.1 → 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.
@@ -8,6 +8,8 @@ import { loadConfig, initConfigDir } from "../src/config.js";
8
8
  import { StandaloneServer } from "../src/standalone-server.js";
9
9
  import { PACKAGE_VERSION } from "../src/protocol.js";
10
10
  import { checkForUpdate, runUpdate } from "../src/update-checker.js";
11
+ import { FileTokenStore } from "../src/token-store.js";
12
+ import { performAuthCodeLogin } from "../src/cli-auth.js";
11
13
  const __filename = fileURLToPath(import.meta.url);
12
14
  const __dirname = dirname(__filename);
13
15
  // After tsc, this file lives at dist/bin/mcp-bridge.js.
@@ -111,6 +113,17 @@ function parseArgs(argv) {
111
113
  case "update":
112
114
  args.command = "update";
113
115
  break;
116
+ case "auth":
117
+ args.command = "auth";
118
+ // Consume subcommand
119
+ if (i + 1 < argv.length) {
120
+ const sub = argv[i + 1];
121
+ if (sub === "login" || sub === "logout" || sub === "status") {
122
+ args.authSubcommand = sub;
123
+ i++;
124
+ }
125
+ }
126
+ break;
114
127
  default:
115
128
  if (!arg.startsWith("-")) {
116
129
  args.positional.push(arg);
@@ -139,6 +152,9 @@ Usage:
139
152
  mcp-bridge servers List configured servers
140
153
  mcp-bridge search <query> Search catalog by keyword
141
154
  mcp-bridge update [--check] Check for / install updates
155
+ mcp-bridge auth login <server> Authenticate with an OAuth2 server
156
+ mcp-bridge auth logout <server> Remove stored token for a server
157
+ mcp-bridge auth status Show auth status for all servers
142
158
 
143
159
  Options:
144
160
  --config PATH Custom config file (default: ~/.mcp-bridge/config.json)
@@ -257,6 +273,115 @@ async function cmdUpdate(logger, checkOnly) {
257
273
  const result = await runUpdate(logger);
258
274
  process.stdout.write(result + "\n");
259
275
  }
276
+ async function cmdAuth(args, logger) {
277
+ const sub = args.authSubcommand;
278
+ if (!sub) {
279
+ process.stderr.write("Usage: mcp-bridge auth <login|logout|status> [server-name]\n");
280
+ process.exit(1);
281
+ }
282
+ const tokenStore = new FileTokenStore();
283
+ if (sub === "status") {
284
+ let config;
285
+ try {
286
+ config = loadConfig({ configPath: args.configPath, logger });
287
+ }
288
+ catch {
289
+ config = null;
290
+ }
291
+ const stored = tokenStore.list();
292
+ if (stored.length === 0 && !config) {
293
+ process.stdout.write("No stored tokens.\n");
294
+ return;
295
+ }
296
+ process.stdout.write("\nAuth status:\n\n");
297
+ process.stdout.write(" Server Auth Type Token Status\n");
298
+ process.stdout.write(" " + "\u2500".repeat(60) + "\n");
299
+ // Show configured servers
300
+ const shown = new Set();
301
+ if (config) {
302
+ for (const [name, serverConfig] of Object.entries(config.servers)) {
303
+ const authType = serverConfig.auth?.type ?? "none";
304
+ const grantType = serverConfig.auth?.type === "oauth2" && "grantType" in serverConfig.auth
305
+ ? serverConfig.auth.grantType
306
+ : serverConfig.auth?.type === "oauth2" ? "client_credentials" : "";
307
+ const label = authType === "oauth2" ? `oauth2 (${grantType})` : authType;
308
+ const token = tokenStore.load(name);
309
+ let status;
310
+ if (token) {
311
+ const now = Date.now();
312
+ if (token.expiresAt > now) {
313
+ const mins = Math.round((token.expiresAt - now) / 60000);
314
+ status = `valid (expires in ${mins}m)`;
315
+ }
316
+ else {
317
+ status = token.refreshToken ? "expired (refresh available)" : "expired";
318
+ }
319
+ }
320
+ else if (grantType === "authorization_code") {
321
+ status = "not authenticated";
322
+ }
323
+ else {
324
+ status = "-";
325
+ }
326
+ process.stdout.write(` ${name.padEnd(16)}${label.padEnd(23)}${status}\n`);
327
+ shown.add(name);
328
+ }
329
+ }
330
+ // Show stored tokens not in config
331
+ for (const { serverName, token } of stored) {
332
+ if (shown.has(serverName))
333
+ continue;
334
+ const now = Date.now();
335
+ const status = token.expiresAt > now
336
+ ? `valid (expires in ${Math.round((token.expiresAt - now) / 60000)}m)`
337
+ : "expired";
338
+ process.stdout.write(` ${serverName.padEnd(16)}${"oauth2 (stored)".padEnd(23)}${status}\n`);
339
+ }
340
+ process.stdout.write("\n");
341
+ return;
342
+ }
343
+ // login / logout need a server name
344
+ const serverName = args.positional[0];
345
+ if (!serverName) {
346
+ process.stderr.write(`Usage: mcp-bridge auth ${sub} <server-name>\n`);
347
+ process.exit(1);
348
+ }
349
+ if (sub === "logout") {
350
+ tokenStore.remove(serverName);
351
+ process.stdout.write(`Removed stored token for ${serverName}\n`);
352
+ return;
353
+ }
354
+ // login
355
+ let config;
356
+ try {
357
+ config = loadConfig({ configPath: args.configPath, logger });
358
+ }
359
+ catch (err) {
360
+ logger.error(err instanceof Error ? err.message : String(err));
361
+ process.exit(1);
362
+ }
363
+ const serverConfig = config.servers[serverName];
364
+ if (!serverConfig) {
365
+ logger.error(`Server "${serverName}" not found in config`);
366
+ process.exit(1);
367
+ }
368
+ const auth = serverConfig.auth;
369
+ if (!auth || auth.type !== "oauth2" || !("grantType" in auth) || auth.grantType !== "authorization_code") {
370
+ logger.error(`Server "${serverName}" is not configured for OAuth2 authorization_code flow`);
371
+ process.exit(1);
372
+ }
373
+ const authCodeAuth = auth;
374
+ const token = await performAuthCodeLogin(serverName, {
375
+ authorizationUrl: authCodeAuth.authorizationUrl,
376
+ tokenUrl: authCodeAuth.tokenUrl,
377
+ clientId: authCodeAuth.clientId,
378
+ clientSecret: authCodeAuth.clientSecret,
379
+ scopes: authCodeAuth.scopes,
380
+ callbackPort: authCodeAuth.callbackPort,
381
+ }, logger);
382
+ tokenStore.save(serverName, token);
383
+ process.stdout.write(`Authentication successful for ${serverName}. Token stored.\n`);
384
+ }
260
385
  async function cmdServe(args, logger) {
261
386
  let config;
262
387
  try {
@@ -325,6 +450,9 @@ async function main() {
325
450
  case "update":
326
451
  await cmdUpdate(logger, args.checkOnly);
327
452
  break;
453
+ case "auth":
454
+ await cmdAuth(args, logger);
455
+ break;
328
456
  case "serve":
329
457
  await cmdServe(args, logger);
330
458
  break;
@@ -0,0 +1,27 @@
1
+ import type { Logger } from "./types.js";
2
+ import type { StoredToken } from "./token-store.js";
3
+ export interface AuthCodeConfig {
4
+ authorizationUrl: string;
5
+ tokenUrl: string;
6
+ clientId?: string;
7
+ clientSecret?: string;
8
+ scopes?: string[];
9
+ callbackPort?: number;
10
+ }
11
+ /**
12
+ * Generate a PKCE code_verifier: 43-128 URL-safe characters.
13
+ */
14
+ export declare function generateCodeVerifier(length?: number): string;
15
+ /**
16
+ * Compute S256 code_challenge from code_verifier.
17
+ */
18
+ export declare function computeCodeChallenge(verifier: string): string;
19
+ /**
20
+ * Perform the full OAuth2 Authorization Code flow with PKCE.
21
+ *
22
+ * 1. Start local HTTP server on callbackPort
23
+ * 2. Open browser to authorization URL
24
+ * 3. Receive callback with authorization code
25
+ * 4. Exchange code for tokens
26
+ */
27
+ export declare function performAuthCodeLogin(serverName: string, authConfig: AuthCodeConfig, logger: Logger): Promise<StoredToken>;
@@ -0,0 +1,199 @@
1
+ import { createServer } from "http";
2
+ import { randomBytes, createHash } from "crypto";
3
+ import { exec } from "child_process";
4
+ import { platform } from "os";
5
+ /** Escape HTML special characters to prevent XSS in callback responses. */
6
+ function escapeHtml(str) {
7
+ return str
8
+ .replace(/&/g, "&amp;")
9
+ .replace(/</g, "&lt;")
10
+ .replace(/>/g, "&gt;")
11
+ .replace(/"/g, "&quot;")
12
+ .replace(/'/g, "&#39;");
13
+ }
14
+ const LOGIN_TIMEOUT_MS = 120_000;
15
+ /**
16
+ * Generate a PKCE code_verifier: 43-128 URL-safe characters.
17
+ */
18
+ export function generateCodeVerifier(length = 64) {
19
+ const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
20
+ const bytes = randomBytes(length);
21
+ let result = "";
22
+ for (let i = 0; i < length; i++) {
23
+ result += charset[bytes[i] % charset.length];
24
+ }
25
+ return result;
26
+ }
27
+ /**
28
+ * Compute S256 code_challenge from code_verifier.
29
+ */
30
+ export function computeCodeChallenge(verifier) {
31
+ return createHash("sha256")
32
+ .update(verifier, "ascii")
33
+ .digest("base64url");
34
+ }
35
+ /**
36
+ * Open a URL in the default browser using platform-specific commands.
37
+ */
38
+ function openBrowser(url, logger) {
39
+ const os = platform();
40
+ let cmd;
41
+ if (os === "darwin") {
42
+ cmd = `open "${url}"`;
43
+ }
44
+ else if (os === "win32") {
45
+ cmd = `start "" "${url}"`;
46
+ }
47
+ else {
48
+ cmd = `xdg-open "${url}"`;
49
+ }
50
+ exec(cmd, (err) => {
51
+ if (err) {
52
+ logger.warn(`[mcp-bridge] Could not open browser automatically. Please visit:\n${url}`);
53
+ }
54
+ });
55
+ }
56
+ const DEFAULT_EXPIRES_IN = 3600;
57
+ const EXPIRY_BUFFER_SECONDS = 60;
58
+ /**
59
+ * Perform the full OAuth2 Authorization Code flow with PKCE.
60
+ *
61
+ * 1. Start local HTTP server on callbackPort
62
+ * 2. Open browser to authorization URL
63
+ * 3. Receive callback with authorization code
64
+ * 4. Exchange code for tokens
65
+ */
66
+ export async function performAuthCodeLogin(serverName, authConfig, logger) {
67
+ const port = authConfig.callbackPort ?? 9876;
68
+ const redirectUri = `http://localhost:${port}/callback`;
69
+ const codeVerifier = generateCodeVerifier();
70
+ const codeChallenge = computeCodeChallenge(codeVerifier);
71
+ const state = randomBytes(16).toString("hex");
72
+ return new Promise((resolve, reject) => {
73
+ let settled = false;
74
+ const timeout = setTimeout(() => {
75
+ if (!settled) {
76
+ settled = true;
77
+ server.close();
78
+ reject(new Error("Authentication timed out after 120 seconds"));
79
+ }
80
+ }, LOGIN_TIMEOUT_MS);
81
+ const server = createServer((req, res) => {
82
+ if (!req.url?.startsWith("/callback")) {
83
+ res.writeHead(404);
84
+ res.end("Not found");
85
+ return;
86
+ }
87
+ const url = new URL(req.url, `http://localhost:${port}`);
88
+ const error = url.searchParams.get("error");
89
+ if (error) {
90
+ res.writeHead(200, { "Content-Type": "text/html" });
91
+ res.end(`<html><body><h2>Authentication failed</h2><p>${escapeHtml(error)}: ${escapeHtml(url.searchParams.get("error_description") || "")}</p></body></html>`);
92
+ if (!settled) {
93
+ settled = true;
94
+ clearTimeout(timeout);
95
+ server.close();
96
+ reject(new Error(`Authorization failed: ${error}`));
97
+ }
98
+ return;
99
+ }
100
+ const returnedState = url.searchParams.get("state");
101
+ if (returnedState !== state) {
102
+ res.writeHead(400, { "Content-Type": "text/html" });
103
+ res.end("<html><body><h2>Invalid state parameter</h2></body></html>");
104
+ if (!settled) {
105
+ settled = true;
106
+ clearTimeout(timeout);
107
+ server.close();
108
+ reject(new Error("OAuth2 state mismatch — possible CSRF attack"));
109
+ }
110
+ return;
111
+ }
112
+ const code = url.searchParams.get("code");
113
+ if (!code) {
114
+ res.writeHead(400, { "Content-Type": "text/html" });
115
+ res.end("<html><body><h2>Missing authorization code</h2></body></html>");
116
+ if (!settled) {
117
+ settled = true;
118
+ clearTimeout(timeout);
119
+ server.close();
120
+ reject(new Error("No authorization code in callback"));
121
+ }
122
+ return;
123
+ }
124
+ res.writeHead(200, { "Content-Type": "text/html" });
125
+ res.end(`<html><body><h2>Authentication successful!</h2><p>You can close this window and return to the terminal.</p></body></html>`);
126
+ if (!settled) {
127
+ settled = true;
128
+ clearTimeout(timeout);
129
+ server.close();
130
+ exchangeCodeForToken(authConfig, code, redirectUri, codeVerifier, logger)
131
+ .then(resolve)
132
+ .catch(reject);
133
+ }
134
+ });
135
+ server.listen(port, () => {
136
+ const params = new URLSearchParams();
137
+ params.set("response_type", "code");
138
+ if (authConfig.clientId)
139
+ params.set("client_id", authConfig.clientId);
140
+ params.set("redirect_uri", redirectUri);
141
+ if (authConfig.scopes?.length)
142
+ params.set("scope", authConfig.scopes.join(" "));
143
+ params.set("state", state);
144
+ params.set("code_challenge", codeChallenge);
145
+ params.set("code_challenge_method", "S256");
146
+ const authUrl = `${authConfig.authorizationUrl}?${params.toString()}`;
147
+ logger.info(`[mcp-bridge] Opening browser for ${serverName} authentication...`);
148
+ logger.info(`[mcp-bridge] If the browser doesn't open, visit: ${authUrl}`);
149
+ openBrowser(authUrl, logger);
150
+ });
151
+ server.on("error", (err) => {
152
+ if (!settled) {
153
+ settled = true;
154
+ clearTimeout(timeout);
155
+ reject(new Error(`Failed to start callback server on port ${port}: ${err.message}`));
156
+ }
157
+ });
158
+ });
159
+ }
160
+ async function exchangeCodeForToken(config, code, redirectUri, codeVerifier, logger) {
161
+ const formData = new URLSearchParams();
162
+ formData.set("grant_type", "authorization_code");
163
+ formData.set("code", code);
164
+ formData.set("redirect_uri", redirectUri);
165
+ formData.set("code_verifier", codeVerifier);
166
+ if (config.clientId)
167
+ formData.set("client_id", config.clientId);
168
+ if (config.clientSecret)
169
+ formData.set("client_secret", config.clientSecret);
170
+ const response = await fetch(config.tokenUrl, {
171
+ method: "POST",
172
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
173
+ body: formData.toString(),
174
+ });
175
+ if (!response.ok) {
176
+ const text = await response.text().catch(() => "");
177
+ throw new Error(`Token exchange failed: HTTP ${response.status} ${text}`);
178
+ }
179
+ const payload = (await response.json());
180
+ if (payload.error) {
181
+ throw new Error(`Token exchange error: ${payload.error} — ${payload.error_description || ""}`);
182
+ }
183
+ if (!payload.access_token) {
184
+ throw new Error("Token exchange response missing access_token");
185
+ }
186
+ const expiresIn = Number.isFinite(payload.expires_in)
187
+ ? Number(payload.expires_in)
188
+ : DEFAULT_EXPIRES_IN;
189
+ const expiresAt = Date.now() + Math.max(0, expiresIn - EXPIRY_BUFFER_SECONDS) * 1000;
190
+ logger.info(`[mcp-bridge] Authentication successful. Token expires in ${expiresIn}s.`);
191
+ return {
192
+ accessToken: payload.access_token,
193
+ refreshToken: payload.refresh_token,
194
+ expiresAt,
195
+ tokenUrl: config.tokenUrl,
196
+ clientId: config.clientId,
197
+ scopes: config.scopes,
198
+ };
199
+ }
@@ -49,10 +49,15 @@ export function parseEnvFile(content) {
49
49
  continue;
50
50
  const key = trimmed.substring(0, eqIdx).trim();
51
51
  let value = trimmed.substring(eqIdx + 1).trim();
52
- // Strip surrounding quotes
52
+ // Strip surrounding quotes and handle escaped quotes within
53
53
  if ((value.startsWith('"') && value.endsWith('"')) ||
54
54
  (value.startsWith("'") && value.endsWith("'"))) {
55
+ const quote = value[0];
55
56
  value = value.slice(1, -1);
57
+ // Unescape escaped quotes: \" → " or \' → '
58
+ value = value.replace(new RegExp(`\\\\${quote}`, "g"), quote);
59
+ // Unescape escaped backslashes: \\ → \
60
+ value = value.replace(/\\\\/g, "\\");
56
61
  }
57
62
  if (key)
58
63
  env[key] = value;
@@ -100,7 +105,10 @@ export function loadConfig(options = {}) {
100
105
  options.logger?.warn(`[mcp-bridge] Failed to parse .env file: ${err instanceof Error ? err.message : err}`);
101
106
  }
102
107
  }
103
- // Merge .env into process.env (don't overwrite existing)
108
+ // Populate process.env with .env values for child processes (don't overwrite
109
+ // existing env vars — this matches dotenv's default behavior). This is separate
110
+ // from the config resolution below, which uses a different merge order where
111
+ // .env values win over process.env.
104
112
  for (const [key, value] of Object.entries(dotEnv)) {
105
113
  if (process.env[key] === undefined) {
106
114
  process.env[key] = value;
@@ -1,9 +1,13 @@
1
- export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveAuthHeadersAsync, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
1
+ export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveAuthHeadersAsync, isAuthCodeOAuth2, resolveOAuth2Config, resolveAuthCodeOAuth2Config, 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
5
  export { OAuth2TokenManager } from "./oauth2-token-manager.js";
6
- export type { OAuth2Config } from "./oauth2-token-manager.js";
6
+ export type { OAuth2Config, AuthCodeOAuth2Config } from "./oauth2-token-manager.js";
7
+ export { FileTokenStore } from "./token-store.js";
8
+ export type { TokenStore, StoredToken } from "./token-store.js";
9
+ export { performAuthCodeLogin, generateCodeVerifier, computeCodeChallenge } from "./cli-auth.js";
10
+ export type { AuthCodeConfig } from "./cli-auth.js";
7
11
  export { McpRouter } from "./mcp-router.js";
8
12
  export type { RouterToolHint, RouterServerStatus, RouterDispatchResponse, RouterTransportRefs } from "./mcp-router.js";
9
13
  export { ResultCache, createResultCacheKey, stableStringify } from "./result-cache.js";
package/dist/src/index.js CHANGED
@@ -1,10 +1,14 @@
1
1
  // Core exports for @aiwerk/mcp-bridge
2
2
  // Transport classes
3
- export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveAuthHeadersAsync, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
3
+ export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveAuthHeadersAsync, isAuthCodeOAuth2, resolveOAuth2Config, resolveAuthCodeOAuth2Config, 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
7
  export { OAuth2TokenManager } from "./oauth2-token-manager.js";
8
+ // Token store
9
+ export { FileTokenStore } from "./token-store.js";
10
+ // CLI auth
11
+ export { performAuthCodeLogin, generateCodeVerifier, computeCodeChallenge } from "./cli-auth.js";
8
12
  // Router
9
13
  export { McpRouter } from "./mcp-router.js";
10
14
  // Result cache
@@ -93,9 +93,9 @@ export type RouterDispatchResponse = {
93
93
  code?: number;
94
94
  };
95
95
  export interface RouterTransportRefs {
96
- sse: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: () => number) => McpTransport;
96
+ sse: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: () => number, serverName?: string) => McpTransport;
97
97
  stdio: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, requestIdGenerator?: () => number) => McpTransport;
98
- streamableHttp: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: () => number) => McpTransport;
98
+ streamableHttp: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: () => number, serverName?: string) => McpTransport;
99
99
  }
100
100
  export declare class McpRouter {
101
101
  private readonly servers;
@@ -129,6 +129,15 @@ export declare class McpRouter {
129
129
  private getPromotionStats;
130
130
  private getRetryPolicy;
131
131
  private classifyTransientError;
132
+ /**
133
+ * Call a tool with automatic retry on transient transport errors.
134
+ *
135
+ * NOTE: Only transport-level errors (timeout, connection_error) are retried.
136
+ * MCP protocol errors (valid JSON-RPC responses with error fields) are NOT
137
+ * retried, because they typically indicate non-transient issues (unknown tool,
138
+ * invalid params, server-side validation failures). The retryOn config
139
+ * intentionally only accepts "timeout" | "connection_error" categories.
140
+ */
132
141
  private callToolWithRetry;
133
142
  disconnectAll(): Promise<void>;
134
143
  shutdown(timeoutMs?: number): Promise<void>;
@@ -11,6 +11,7 @@ import { AdaptivePromotion } from "./adaptive-promotion.js";
11
11
  import { ResultCache, createResultCacheKey } from "./result-cache.js";
12
12
  import { ToolResolver } from "./tool-resolution.js";
13
13
  import { OAuth2TokenManager } from "./oauth2-token-manager.js";
14
+ import { FileTokenStore } from "./token-store.js";
14
15
  const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
15
16
  const DEFAULT_CONNECT_ERROR_COOLDOWN_MS = 10 * 1000;
16
17
  const DEFAULT_MAX_CONCURRENT = 5;
@@ -53,7 +54,7 @@ export class McpRouter {
53
54
  : null;
54
55
  this.maxBatchSize = clientConfig.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
55
56
  this.toolResolver = new ToolResolver(Object.keys(servers));
56
- this.tokenManager = new OAuth2TokenManager(logger);
57
+ this.tokenManager = new OAuth2TokenManager(logger, new FileTokenStore());
57
58
  if (clientConfig.adaptivePromotion?.enabled) {
58
59
  this.promotion = new AdaptivePromotion(clientConfig.adaptivePromotion, logger);
59
60
  }
@@ -98,28 +99,41 @@ export class McpRouter {
98
99
  if (calls.length > this.maxBatchSize) {
99
100
  return this.error("invalid_params", `batch size exceeds maxBatchSize (${this.maxBatchSize})`);
100
101
  }
101
- const results = await Promise.all(calls.map(async (call) => {
102
- const callServer = typeof call?.server === "string" ? call.server : "";
103
- const callTool = typeof call?.tool === "string" ? call.tool : "";
104
- const response = await this.dispatch(callServer, "call", callTool, call?.params);
105
- if ("error" in response) {
106
- return {
107
- server: callServer,
108
- tool: callTool,
109
- error: {
110
- error: response.error,
111
- message: response.message,
112
- ...(response.available ? { available: response.available } : {}),
113
- ...(typeof response.code === "number" ? { code: response.code } : {})
114
- }
115
- };
102
+ // Throttled batch execution: max 3 concurrent calls per server
103
+ // to avoid overloading individual backend servers.
104
+ const MAX_BATCH_CONCURRENCY = 3;
105
+ const results = new Array(calls.length);
106
+ let nextIndex = 0;
107
+ const executeNext = async () => {
108
+ while (nextIndex < calls.length) {
109
+ const idx = nextIndex++;
110
+ const call = calls[idx];
111
+ const callServer = typeof call?.server === "string" ? call.server : "";
112
+ const callTool = typeof call?.tool === "string" ? call.tool : "";
113
+ const response = await this.dispatch(callServer, "call", callTool, call?.params);
114
+ if ("error" in response) {
115
+ results[idx] = {
116
+ server: callServer,
117
+ tool: callTool,
118
+ error: {
119
+ error: response.error,
120
+ message: response.message,
121
+ ...(response.available ? { available: response.available } : {}),
122
+ ...(typeof response.code === "number" ? { code: response.code } : {})
123
+ }
124
+ };
125
+ }
126
+ else {
127
+ results[idx] = {
128
+ server: callServer,
129
+ tool: callTool,
130
+ result: "result" in response ? response.result : response
131
+ };
132
+ }
116
133
  }
117
- return {
118
- server: callServer,
119
- tool: callTool,
120
- result: "result" in response ? response.result : response
121
- };
122
- }));
134
+ };
135
+ const workers = Array.from({ length: Math.min(MAX_BATCH_CONCURRENCY, calls.length) }, () => executeNext());
136
+ await Promise.all(workers);
123
137
  return { action: "batch", results };
124
138
  }
125
139
  if (normalizedAction === "list") {
@@ -431,6 +445,15 @@ export class McpRouter {
431
445
  }
432
446
  return null;
433
447
  }
448
+ /**
449
+ * Call a tool with automatic retry on transient transport errors.
450
+ *
451
+ * NOTE: Only transport-level errors (timeout, connection_error) are retried.
452
+ * MCP protocol errors (valid JSON-RPC responses with error fields) are NOT
453
+ * retried, because they typically indicate non-transient issues (unknown tool,
454
+ * invalid params, server-side validation failures). The retryOn config
455
+ * intentionally only accepts "timeout" | "connection_error" categories.
456
+ */
434
457
  async callToolWithRetry(server, tool, args, transport) {
435
458
  const retryPolicy = this.getRetryPolicy(server);
436
459
  let retries = 0;
@@ -543,14 +566,17 @@ export class McpRouter {
543
566
  };
544
567
  throw normalizedError;
545
568
  }
569
+ finally {
570
+ // Clear initPromise here (inside the async IIFE) so concurrent
571
+ // callers that await the same promise see it cleared atomically
572
+ // with the lastConnectError being set. Previously this was in the
573
+ // outer finally block, creating a window where a concurrent caller
574
+ // could bypass the cooldown check.
575
+ state.initPromise = undefined;
576
+ }
546
577
  })();
547
- try {
548
- await state.initPromise;
549
- return state;
550
- }
551
- finally {
552
- state.initPromise = undefined;
553
- }
578
+ await state.initPromise;
579
+ return state;
554
580
  }
555
581
  async enforceMaxConcurrent(activeServer) {
556
582
  const connectedServers = [...this.states.entries()]
@@ -621,13 +647,13 @@ export class McpRouter {
621
647
  this.resultCache?.invalidate(`${serverName}:`);
622
648
  };
623
649
  if (serverConfig.transport === "sse") {
624
- return new this.transportRefs.sse(serverConfig, this.clientConfig, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId());
650
+ return new this.transportRefs.sse(serverConfig, this.clientConfig, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId(), serverName);
625
651
  }
626
652
  if (serverConfig.transport === "stdio") {
627
653
  return new this.transportRefs.stdio(serverConfig, this.clientConfig, this.logger, onReconnected, () => this.nextRequestId());
628
654
  }
629
655
  if (serverConfig.transport === "streamable-http") {
630
- return new this.transportRefs.streamableHttp(serverConfig, this.clientConfig, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId());
656
+ return new this.transportRefs.streamableHttp(serverConfig, this.clientConfig, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId(), serverName);
631
657
  }
632
658
  throw new Error(`Unsupported transport: ${serverConfig.transport}`);
633
659
  }
@@ -1,4 +1,5 @@
1
1
  import type { Logger } from "./types.js";
2
+ import type { TokenStore } from "./token-store.js";
2
3
  export interface OAuth2Config {
3
4
  clientId: string;
4
5
  clientSecret: string;
@@ -6,14 +7,30 @@ export interface OAuth2Config {
6
7
  scopes?: string[];
7
8
  audience?: string;
8
9
  }
10
+ export interface AuthCodeOAuth2Config {
11
+ grantType: "authorization_code";
12
+ tokenUrl: string;
13
+ clientId?: string;
14
+ clientSecret?: string;
15
+ scopes?: string[];
16
+ }
9
17
  export declare class OAuth2TokenManager {
10
18
  private readonly logger;
11
19
  private readonly tokenCache;
12
20
  private readonly inflight;
13
- constructor(logger: Logger);
21
+ private readonly authCodeInflight;
22
+ private readonly tokenStore?;
23
+ constructor(logger: Logger, tokenStore?: TokenStore);
14
24
  getToken(config: OAuth2Config): Promise<string>;
15
25
  invalidate(tokenUrl: string, clientId: string): void;
16
26
  clear(): void;
27
+ /**
28
+ * Get a token for an authorization_code flow server.
29
+ * Checks TokenStore, refreshes if expired, throws if unavailable.
30
+ */
31
+ getTokenForAuthCode(serverName: string, config: AuthCodeOAuth2Config): Promise<string>;
32
+ private doAuthCodeRefresh;
33
+ private refreshAuthCodeToken;
17
34
  private makeKey;
18
35
  private fetchToken;
19
36
  private exchangeToken;