@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 +36 -2
- package/dist/bin/mcp-bridge.js +145 -0
- package/dist/src/cli-auth.d.ts +41 -0
- package/dist/src/cli-auth.js +300 -0
- package/dist/src/config.js +10 -2
- package/dist/src/index.d.ts +6 -2
- package/dist/src/index.js +5 -1
- package/dist/src/mcp-router.d.ts +11 -2
- package/dist/src/mcp-router.js +57 -31
- package/dist/src/oauth2-token-manager.d.ts +31 -1
- package/dist/src/oauth2-token-manager.js +171 -1
- package/dist/src/security.d.ts +4 -0
- package/dist/src/security.js +88 -1
- package/dist/src/standalone-server.js +4 -3
- package/dist/src/token-store.d.ts +30 -0
- package/dist/src/token-store.js +69 -0
- package/dist/src/transport-base.d.ts +15 -3
- package/dist/src/transport-base.js +67 -9
- package/dist/src/transport-sse.d.ts +2 -1
- package/dist/src/transport-sse.js +8 -3
- package/dist/src/transport-stdio.js +1 -1
- package/dist/src/transport-streamable-http.d.ts +2 -1
- package/dist/src/transport-streamable-http.js +47 -16
- package/dist/src/types.d.ts +16 -0
- package/package.json +2 -2
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
|
-
|
|
|
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 |
|
package/dist/bin/mcp-bridge.js
CHANGED
|
@@ -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, "&")
|
|
9
|
+
.replace(/</g, "<")
|
|
10
|
+
.replace(/>/g, ">")
|
|
11
|
+
.replace(/"/g, """)
|
|
12
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
-
//
|
|
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;
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
package/dist/src/mcp-router.d.ts
CHANGED
|
@@ -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>;
|