@aiwerk/mcp-bridge 2.5.2 → 2.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/mcp-bridge.js +128 -0
- package/dist/src/cli-auth.d.ts +27 -0
- package/dist/src/cli-auth.js +199 -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 +18 -1
- package/dist/src/oauth2-token-manager.js +90 -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 +9 -3
- package/dist/src/transport-base.js +33 -4
- 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 +9 -0
- package/package.json +2 -2
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 } 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, "&")
|
|
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
|
+
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
|
+
}
|
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>;
|
package/dist/src/mcp-router.js
CHANGED
|
@@ -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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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;
|