@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.
package/README.md CHANGED
@@ -400,7 +400,7 @@ OAuth2 features: automatic token acquisition, caching with expiry-aware refresh,
400
400
 
401
401
  **OAuth2 Authorization Code + PKCE** (interactive browser login):
402
402
 
403
- For MCP servers behind enterprise SSO or user-level OAuth2 that require browser-based login:
403
+ For MCP servers behind enterprise SSO or user-level OAuth2 that require browser-based login (desktop/laptop):
404
404
 
405
405
  ```json
406
406
  {
@@ -429,6 +429,40 @@ Features:
429
429
  - **Automatic refresh** — tokens refreshed transparently via `refresh_token` grant
430
430
  - **Actionable errors** — expired tokens return error with exact CLI command to re-authenticate
431
431
 
432
+ **OAuth2 Device Code** (headless environments — VPS, Docker, SSH, CI):
433
+
434
+ For environments without a browser. You authenticate on a separate device using a short code:
435
+
436
+ ```json
437
+ {
438
+ "auth": {
439
+ "type": "oauth2",
440
+ "grantType": "device_code",
441
+ "deviceAuthorizationUrl": "https://github.com/login/device/code",
442
+ "tokenUrl": "https://github.com/login/oauth/access_token",
443
+ "clientId": "your-app-id",
444
+ "scopes": ["repo", "read:org"]
445
+ }
446
+ }
447
+ ```
448
+
449
+ ```bash
450
+ mcp-bridge auth login my-server
451
+ # ──────────────────────────────────────────
452
+ # Device authentication for "my-server"
453
+ #
454
+ # 1. Open: https://github.com/login/device
455
+ # 2. Enter code: ABCD-1234
456
+ # ──────────────────────────────────────────
457
+ # Waiting for authorization...
458
+ ```
459
+
460
+ Features:
461
+ - **RFC 8628 compliant** — works with GitHub, Google, Microsoft, Auth0, Okta
462
+ - **No local browser needed** — authenticate from phone/laptop, token received on server
463
+ - **Automatic polling** — respects `interval` and `slow_down` responses
464
+ - **Same token persistence** — stored in `~/.mcp-bridge/tokens/` with auto-refresh
465
+
432
466
  ### Environment variables
433
467
 
434
468
  Secrets go in `~/.mcp-bridge/.env` (chmod 600 on init):
@@ -556,7 +590,7 @@ For production deployments with high security requirements, consider adding an e
556
590
  | ✅ | Configurable retries + graceful shutdown | 2.0.0 |
557
591
  | ✅ | OAuth2 Client Credentials | 2.1.0 |
558
592
  | ✅ | OAuth2 Authorization Code + PKCE | 2.5.0 |
559
- | 🔜 | OAuth2 Device Code flow (headless) | planned |
593
+ | | OAuth2 Device Code flow (headless) | 2.6.0 |
560
594
  | 🔜 | Hosted bridge (bridge.aiwerk.ch) | planned |
561
595
  | 🔜 | Remote catalog integration | planned |
562
596
  | 🔜 | OpenTelemetry / Prometheus metrics | planned |
@@ -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, performDeviceCodeLogin } 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,132 @@ 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" || grantType === "device_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)) {
370
+ logger.error(`Server "${serverName}" is not configured for an interactive OAuth2 flow (authorization_code or device_code)`);
371
+ process.exit(1);
372
+ }
373
+ const grantType = auth.grantType;
374
+ let token;
375
+ if (grantType === "device_code") {
376
+ const deviceAuth = auth;
377
+ token = await performDeviceCodeLogin(serverName, {
378
+ deviceAuthorizationUrl: deviceAuth.deviceAuthorizationUrl,
379
+ tokenUrl: deviceAuth.tokenUrl,
380
+ clientId: deviceAuth.clientId,
381
+ scopes: deviceAuth.scopes,
382
+ }, logger);
383
+ }
384
+ else if (grantType === "authorization_code") {
385
+ const authCodeAuth = auth;
386
+ token = await performAuthCodeLogin(serverName, {
387
+ authorizationUrl: authCodeAuth.authorizationUrl,
388
+ tokenUrl: authCodeAuth.tokenUrl,
389
+ clientId: authCodeAuth.clientId,
390
+ clientSecret: authCodeAuth.clientSecret,
391
+ scopes: authCodeAuth.scopes,
392
+ callbackPort: authCodeAuth.callbackPort,
393
+ }, logger);
394
+ }
395
+ else {
396
+ logger.error(`Server "${serverName}" uses grant type "${grantType}" which does not support interactive login`);
397
+ process.exit(1);
398
+ }
399
+ tokenStore.save(serverName, token);
400
+ process.stdout.write(`Authentication successful for ${serverName}. Token stored.\n`);
401
+ }
260
402
  async function cmdServe(args, logger) {
261
403
  let config;
262
404
  try {
@@ -325,6 +467,9 @@ async function main() {
325
467
  case "update":
326
468
  await cmdUpdate(logger, args.checkOnly);
327
469
  break;
470
+ case "auth":
471
+ await cmdAuth(args, logger);
472
+ break;
328
473
  case "serve":
329
474
  await cmdServe(args, logger);
330
475
  break;
@@ -0,0 +1,41 @@
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>;
28
+ export interface DeviceCodeConfig {
29
+ deviceAuthorizationUrl: string;
30
+ tokenUrl: string;
31
+ clientId: string;
32
+ scopes?: string[];
33
+ }
34
+ /**
35
+ * Perform the OAuth2 Device Authorization Grant (RFC 8628).
36
+ *
37
+ * 1. POST to deviceAuthorizationUrl to obtain device_code + user_code
38
+ * 2. Display user_code and verification_uri to the user
39
+ * 3. Poll tokenUrl until the user authorizes or the code expires
40
+ */
41
+ export declare function performDeviceCodeLogin(serverName: string, config: DeviceCodeConfig, logger: Logger): Promise<StoredToken>;
@@ -0,0 +1,300 @@
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
+ const DEFAULT_POLL_INTERVAL_S = 5;
161
+ const SLOW_DOWN_INCREMENT_S = 5;
162
+ /**
163
+ * Perform the OAuth2 Device Authorization Grant (RFC 8628).
164
+ *
165
+ * 1. POST to deviceAuthorizationUrl to obtain device_code + user_code
166
+ * 2. Display user_code and verification_uri to the user
167
+ * 3. Poll tokenUrl until the user authorizes or the code expires
168
+ */
169
+ export async function performDeviceCodeLogin(serverName, config, logger) {
170
+ // Step 1: Request device code
171
+ const formData = new URLSearchParams();
172
+ formData.set("client_id", config.clientId);
173
+ if (config.scopes?.length)
174
+ formData.set("scope", config.scopes.join(" "));
175
+ const deviceResponse = await fetch(config.deviceAuthorizationUrl, {
176
+ method: "POST",
177
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
178
+ body: formData.toString(),
179
+ });
180
+ if (!deviceResponse.ok) {
181
+ const text = await deviceResponse.text().catch(() => "");
182
+ throw new Error(`Device authorization request failed: HTTP ${deviceResponse.status} ${text}`);
183
+ }
184
+ const devicePayload = (await deviceResponse.json());
185
+ if (devicePayload.error) {
186
+ throw new Error(`Device authorization error: ${devicePayload.error} — ${devicePayload.error_description || ""}`);
187
+ }
188
+ if (!devicePayload.device_code || !devicePayload.user_code || !devicePayload.verification_uri) {
189
+ throw new Error("Device authorization response missing required fields (device_code, user_code, verification_uri)");
190
+ }
191
+ const deviceCode = devicePayload.device_code;
192
+ const userCode = devicePayload.user_code;
193
+ const verificationUri = devicePayload.verification_uri;
194
+ const verificationUriComplete = devicePayload.verification_uri_complete;
195
+ const expiresInS = devicePayload.expires_in ?? 900;
196
+ let intervalS = devicePayload.interval ?? DEFAULT_POLL_INTERVAL_S;
197
+ // Step 2: Display instructions to the user
198
+ logger.info(`[mcp-bridge] ──────────────────────────────────────────`);
199
+ logger.info(`[mcp-bridge] Device authentication for "${serverName}"`);
200
+ logger.info(`[mcp-bridge]`);
201
+ logger.info(`[mcp-bridge] 1. Open: ${verificationUri}`);
202
+ logger.info(`[mcp-bridge] 2. Enter code: ${userCode}`);
203
+ logger.info(`[mcp-bridge] ──────────────────────────────────────────`);
204
+ if (verificationUriComplete) {
205
+ logger.info(`[mcp-bridge] Or open this URL directly: ${verificationUriComplete}`);
206
+ openBrowser(verificationUriComplete, logger);
207
+ }
208
+ logger.info(`[mcp-bridge] Waiting for authorization (expires in ${expiresInS}s)...`);
209
+ // Step 3: Poll for token
210
+ const deadline = Date.now() + expiresInS * 1000;
211
+ while (Date.now() < deadline) {
212
+ await sleep(intervalS * 1000);
213
+ const tokenForm = new URLSearchParams();
214
+ tokenForm.set("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
215
+ tokenForm.set("device_code", deviceCode);
216
+ tokenForm.set("client_id", config.clientId);
217
+ const tokenResponse = await fetch(config.tokenUrl, {
218
+ method: "POST",
219
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
220
+ body: tokenForm.toString(),
221
+ });
222
+ const tokenPayload = (await tokenResponse.json());
223
+ if (tokenPayload.error) {
224
+ if (tokenPayload.error === "authorization_pending") {
225
+ continue;
226
+ }
227
+ if (tokenPayload.error === "slow_down") {
228
+ intervalS += SLOW_DOWN_INCREMENT_S;
229
+ continue;
230
+ }
231
+ if (tokenPayload.error === "expired_token") {
232
+ throw new Error("Device code expired. Please try again.");
233
+ }
234
+ if (tokenPayload.error === "access_denied") {
235
+ throw new Error("Authorization denied by user.");
236
+ }
237
+ throw new Error(`Device code token error: ${tokenPayload.error} — ${tokenPayload.error_description || ""}`);
238
+ }
239
+ if (!tokenPayload.access_token) {
240
+ throw new Error("Device code token response missing access_token");
241
+ }
242
+ const expiresIn = Number.isFinite(tokenPayload.expires_in)
243
+ ? Number(tokenPayload.expires_in)
244
+ : DEFAULT_EXPIRES_IN;
245
+ const expiresAt = Date.now() + Math.max(0, expiresIn - EXPIRY_BUFFER_SECONDS) * 1000;
246
+ logger.info(`[mcp-bridge] Authentication successful. Token expires in ${expiresIn}s.`);
247
+ return {
248
+ accessToken: tokenPayload.access_token,
249
+ refreshToken: tokenPayload.refresh_token,
250
+ expiresAt,
251
+ tokenUrl: config.tokenUrl,
252
+ clientId: config.clientId,
253
+ scopes: config.scopes,
254
+ };
255
+ }
256
+ throw new Error("Device code expired (timeout). Please try again.");
257
+ }
258
+ function sleep(ms) {
259
+ return new Promise((resolve) => setTimeout(resolve, ms));
260
+ }
261
+ async function exchangeCodeForToken(config, code, redirectUri, codeVerifier, logger) {
262
+ const formData = new URLSearchParams();
263
+ formData.set("grant_type", "authorization_code");
264
+ formData.set("code", code);
265
+ formData.set("redirect_uri", redirectUri);
266
+ formData.set("code_verifier", codeVerifier);
267
+ if (config.clientId)
268
+ formData.set("client_id", config.clientId);
269
+ if (config.clientSecret)
270
+ formData.set("client_secret", config.clientSecret);
271
+ const response = await fetch(config.tokenUrl, {
272
+ method: "POST",
273
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
274
+ body: formData.toString(),
275
+ });
276
+ if (!response.ok) {
277
+ const text = await response.text().catch(() => "");
278
+ throw new Error(`Token exchange failed: HTTP ${response.status} ${text}`);
279
+ }
280
+ const payload = (await response.json());
281
+ if (payload.error) {
282
+ throw new Error(`Token exchange error: ${payload.error} — ${payload.error_description || ""}`);
283
+ }
284
+ if (!payload.access_token) {
285
+ throw new Error("Token exchange response missing access_token");
286
+ }
287
+ const expiresIn = Number.isFinite(payload.expires_in)
288
+ ? Number(payload.expires_in)
289
+ : DEFAULT_EXPIRES_IN;
290
+ const expiresAt = Date.now() + Math.max(0, expiresIn - EXPIRY_BUFFER_SECONDS) * 1000;
291
+ logger.info(`[mcp-bridge] Authentication successful. Token expires in ${expiresIn}s.`);
292
+ return {
293
+ accessToken: payload.access_token,
294
+ refreshToken: payload.refresh_token,
295
+ expiresAt,
296
+ tokenUrl: config.tokenUrl,
297
+ clientId: config.clientId,
298
+ scopes: config.scopes,
299
+ };
300
+ }
@@ -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>;