@aiwerk/mcp-bridge 1.9.0 → 2.1.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/dist/bin/mcp-bridge.js +1 -1
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +2 -1
- package/dist/src/mcp-router.d.ts +9 -2
- package/dist/src/mcp-router.js +108 -11
- package/dist/src/oauth2-token-manager.d.ts +20 -0
- package/dist/src/oauth2-token-manager.js +98 -0
- package/dist/src/standalone-server.d.ts +1 -0
- package/dist/src/standalone-server.js +7 -3
- package/dist/src/tool-resolution.d.ts +1 -0
- package/dist/src/tool-resolution.js +5 -0
- package/dist/src/transport-base.d.ts +12 -0
- package/dist/src/transport-base.js +53 -0
- package/dist/src/transport-sse.d.ts +10 -1
- package/dist/src/transport-sse.js +93 -29
- package/dist/src/transport-stdio.d.ts +1 -0
- package/dist/src/transport-stdio.js +20 -8
- package/dist/src/transport-streamable-http.d.ts +11 -1
- package/dist/src/transport-streamable-http.js +152 -84
- package/dist/src/types.d.ts +26 -4
- package/package.json +1 -1
package/dist/bin/mcp-bridge.js
CHANGED
|
@@ -267,7 +267,7 @@ async function cmdServe(args, logger) {
|
|
|
267
267
|
process.exit(1);
|
|
268
268
|
}
|
|
269
269
|
// HTTP modes: require auth
|
|
270
|
-
if ((args.sse || args.http) && !config.http?.auth
|
|
270
|
+
if ((args.sse || args.http) && !config.http?.auth) {
|
|
271
271
|
logger.error("HTTP auth not configured. Set http.auth in config or use stdio mode.");
|
|
272
272
|
process.exit(1);
|
|
273
273
|
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, warnIfNonTlsRemoteUrl } from "./transport-base.js";
|
|
1
|
+
export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveAuthHeadersAsync, resolveOAuth2Config, 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
|
+
export { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
6
|
+
export type { OAuth2Config } from "./oauth2-token-manager.js";
|
|
5
7
|
export { McpRouter } from "./mcp-router.js";
|
|
6
8
|
export type { RouterToolHint, RouterServerStatus, RouterDispatchResponse, RouterTransportRefs } from "./mcp-router.js";
|
|
7
9
|
export { ResultCache, createResultCacheKey, stableStringify } from "./result-cache.js";
|
|
@@ -11,7 +13,7 @@ export type { ToolResolutionResult, ToolResolutionCandidate } from "./tool-resol
|
|
|
11
13
|
export { convertJsonSchemaToTypeBox, createToolParameters, setTypeBoxLoader, setSchemaLogger } from "./schema-convert.js";
|
|
12
14
|
export { initializeProtocol, fetchToolsList, PACKAGE_VERSION } from "./protocol.js";
|
|
13
15
|
export { loadConfig, parseEnvFile, initConfigDir, getConfigDir } from "./config.js";
|
|
14
|
-
export type { Logger, McpServerConfig, McpClientConfig, McpTool, McpRequest, McpCallRequest, McpResponse, JsonRpcMessage, McpTransport, McpServerConnection, BridgeConfig, } from "./types.js";
|
|
16
|
+
export type { Logger, McpServerConfig, McpClientConfig, HttpAuthConfig, RetryConfig, McpTool, McpRequest, McpCallRequest, McpResponse, JsonRpcMessage, McpTransport, McpServerConnection, BridgeConfig, } from "./types.js";
|
|
15
17
|
export { nextRequestId } from "./types.js";
|
|
16
18
|
export { pickRegisteredToolName } from "./tool-naming.js";
|
|
17
19
|
export { StandaloneServer } from "./standalone-server.js";
|
package/dist/src/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Core exports for @aiwerk/mcp-bridge
|
|
2
2
|
// Transport classes
|
|
3
|
-
export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, warnIfNonTlsRemoteUrl } from "./transport-base.js";
|
|
3
|
+
export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveAuthHeadersAsync, resolveOAuth2Config, 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
|
+
export { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
7
8
|
// Router
|
|
8
9
|
export { McpRouter } from "./mcp-router.js";
|
|
9
10
|
// Result cache
|
package/dist/src/mcp-router.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpClientConfig, McpServerConfig, McpTransport, Logger } from "./types.js";
|
|
2
|
+
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
2
3
|
type RouterErrorCode = "unknown_server" | "unknown_tool" | "connection_failed" | "mcp_error" | "invalid_params";
|
|
3
4
|
interface RouterBatchResult {
|
|
4
5
|
server: string;
|
|
@@ -37,6 +38,7 @@ export type RouterDispatchResponse = {
|
|
|
37
38
|
action: "call";
|
|
38
39
|
tool: string;
|
|
39
40
|
result: any;
|
|
41
|
+
retries?: number;
|
|
40
42
|
} | {
|
|
41
43
|
server: string;
|
|
42
44
|
action: "schema";
|
|
@@ -91,9 +93,9 @@ export type RouterDispatchResponse = {
|
|
|
91
93
|
code?: number;
|
|
92
94
|
};
|
|
93
95
|
export interface RouterTransportRefs {
|
|
94
|
-
sse: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void
|
|
96
|
+
sse: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager) => McpTransport;
|
|
95
97
|
stdio: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>) => McpTransport;
|
|
96
|
-
streamableHttp: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void
|
|
98
|
+
streamableHttp: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager) => McpTransport;
|
|
97
99
|
}
|
|
98
100
|
export declare class McpRouter {
|
|
99
101
|
private readonly servers;
|
|
@@ -106,6 +108,7 @@ export declare class McpRouter {
|
|
|
106
108
|
private readonly maxBatchSize;
|
|
107
109
|
private readonly states;
|
|
108
110
|
private readonly toolResolver;
|
|
111
|
+
private readonly tokenManager;
|
|
109
112
|
private intentRouter;
|
|
110
113
|
private promotion;
|
|
111
114
|
constructor(servers: Record<string, McpServerConfig>, clientConfig: McpClientConfig, logger: Logger, transportRefs?: Partial<RouterTransportRefs>);
|
|
@@ -122,7 +125,11 @@ export declare class McpRouter {
|
|
|
122
125
|
inputSchema: any;
|
|
123
126
|
}>;
|
|
124
127
|
private getPromotionStats;
|
|
128
|
+
private getRetryPolicy;
|
|
129
|
+
private classifyTransientError;
|
|
130
|
+
private callToolWithRetry;
|
|
125
131
|
disconnectAll(): Promise<void>;
|
|
132
|
+
shutdown(timeoutMs?: number): Promise<void>;
|
|
126
133
|
private ensureConnected;
|
|
127
134
|
private enforceMaxConcurrent;
|
|
128
135
|
private disconnectServer;
|
package/dist/src/mcp-router.js
CHANGED
|
@@ -9,9 +9,11 @@ import { isToolAllowed, processResult } from "./security.js";
|
|
|
9
9
|
import { AdaptivePromotion } from "./adaptive-promotion.js";
|
|
10
10
|
import { ResultCache, createResultCacheKey } from "./result-cache.js";
|
|
11
11
|
import { ToolResolver } from "./tool-resolution.js";
|
|
12
|
+
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
12
13
|
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
13
14
|
const DEFAULT_MAX_CONCURRENT = 5;
|
|
14
15
|
const DEFAULT_MAX_BATCH_SIZE = 10;
|
|
16
|
+
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000;
|
|
15
17
|
export class McpRouter {
|
|
16
18
|
servers;
|
|
17
19
|
clientConfig;
|
|
@@ -23,6 +25,7 @@ export class McpRouter {
|
|
|
23
25
|
maxBatchSize;
|
|
24
26
|
states = new Map();
|
|
25
27
|
toolResolver;
|
|
28
|
+
tokenManager;
|
|
26
29
|
intentRouter = null;
|
|
27
30
|
promotion = null;
|
|
28
31
|
constructor(servers, clientConfig, logger, transportRefs) {
|
|
@@ -45,6 +48,7 @@ export class McpRouter {
|
|
|
45
48
|
: null;
|
|
46
49
|
this.maxBatchSize = clientConfig.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
|
|
47
50
|
this.toolResolver = new ToolResolver(Object.keys(servers));
|
|
51
|
+
this.tokenManager = new OAuth2TokenManager(logger);
|
|
48
52
|
if (clientConfig.adaptivePromotion?.enabled) {
|
|
49
53
|
this.promotion = new AdaptivePromotion(clientConfig.adaptivePromotion, logger);
|
|
50
54
|
}
|
|
@@ -227,14 +231,8 @@ export class McpRouter {
|
|
|
227
231
|
}
|
|
228
232
|
}
|
|
229
233
|
this.markUsed(server);
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
method: "tools/call",
|
|
233
|
-
params: {
|
|
234
|
-
name: tool,
|
|
235
|
-
arguments: params ?? {}
|
|
236
|
-
}
|
|
237
|
-
});
|
|
234
|
+
const callOutcome = await this.callToolWithRetry(server, tool, params ?? {}, state.transport);
|
|
235
|
+
const response = callOutcome.response;
|
|
238
236
|
if (response.error) {
|
|
239
237
|
return this.error("mcp_error", response.error.message, undefined, response.error.code);
|
|
240
238
|
}
|
|
@@ -248,7 +246,13 @@ export class McpRouter {
|
|
|
248
246
|
if (this.resultCache && cacheKey) {
|
|
249
247
|
this.resultCache.set(cacheKey, result);
|
|
250
248
|
}
|
|
251
|
-
return {
|
|
249
|
+
return {
|
|
250
|
+
server,
|
|
251
|
+
action: "call",
|
|
252
|
+
tool,
|
|
253
|
+
result,
|
|
254
|
+
...(callOutcome.retries > 0 ? { retries: callOutcome.retries } : {})
|
|
255
|
+
};
|
|
252
256
|
}
|
|
253
257
|
catch (error) {
|
|
254
258
|
return this.error("mcp_error", error instanceof Error ? error.message : String(error));
|
|
@@ -390,11 +394,104 @@ export class McpRouter {
|
|
|
390
394
|
}));
|
|
391
395
|
return { action: "promotions", promoted, stats };
|
|
392
396
|
}
|
|
397
|
+
getRetryPolicy(server) {
|
|
398
|
+
const globalRetry = this.clientConfig.retry ?? {};
|
|
399
|
+
const serverRetry = this.servers[server].retry ?? {};
|
|
400
|
+
const maxAttemptsRaw = serverRetry.maxAttempts ?? globalRetry.maxAttempts ?? 1;
|
|
401
|
+
const delayMsRaw = serverRetry.delayMs ?? globalRetry.delayMs ?? 1000;
|
|
402
|
+
const backoffMultiplierRaw = serverRetry.backoffMultiplier ?? globalRetry.backoffMultiplier ?? 2;
|
|
403
|
+
const retryOn = serverRetry.retryOn ?? globalRetry.retryOn ?? ["timeout", "connection_error"];
|
|
404
|
+
return {
|
|
405
|
+
maxAttempts: Math.max(1, Math.floor(maxAttemptsRaw)),
|
|
406
|
+
delayMs: Math.max(0, Math.floor(delayMsRaw)),
|
|
407
|
+
backoffMultiplier: Math.max(1, backoffMultiplierRaw),
|
|
408
|
+
retryOn: new Set(retryOn)
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
classifyTransientError(error) {
|
|
412
|
+
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
413
|
+
if (message.includes("timeout") ||
|
|
414
|
+
message.includes("timed out") ||
|
|
415
|
+
message.includes("abort")) {
|
|
416
|
+
return "timeout";
|
|
417
|
+
}
|
|
418
|
+
if (message.includes("connection") ||
|
|
419
|
+
message.includes("econnreset") ||
|
|
420
|
+
message.includes("socket hang up") ||
|
|
421
|
+
message.includes("network") ||
|
|
422
|
+
message.includes("fetch failed") ||
|
|
423
|
+
message.includes("econnrefused") ||
|
|
424
|
+
message.includes("enotfound")) {
|
|
425
|
+
return "connection_error";
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
async callToolWithRetry(server, tool, args, transport) {
|
|
430
|
+
const retryPolicy = this.getRetryPolicy(server);
|
|
431
|
+
let retries = 0;
|
|
432
|
+
let lastError;
|
|
433
|
+
for (let attempt = 0; attempt < retryPolicy.maxAttempts; attempt++) {
|
|
434
|
+
try {
|
|
435
|
+
const response = await transport.sendRequest({
|
|
436
|
+
jsonrpc: "2.0",
|
|
437
|
+
method: "tools/call",
|
|
438
|
+
params: {
|
|
439
|
+
name: tool,
|
|
440
|
+
arguments: args
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
return { response, retries };
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
lastError = error;
|
|
447
|
+
const category = this.classifyTransientError(error);
|
|
448
|
+
const shouldRetry = category !== null &&
|
|
449
|
+
retryPolicy.retryOn.has(category) &&
|
|
450
|
+
attempt < retryPolicy.maxAttempts - 1;
|
|
451
|
+
if (!shouldRetry) {
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
retries += 1;
|
|
455
|
+
const delay = retryPolicy.delayMs * Math.pow(retryPolicy.backoffMultiplier, attempt);
|
|
456
|
+
if (delay > 0) {
|
|
457
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
462
|
+
}
|
|
393
463
|
async disconnectAll() {
|
|
394
464
|
for (const serverName of Object.keys(this.servers)) {
|
|
395
465
|
await this.disconnectServer(serverName);
|
|
396
466
|
}
|
|
397
467
|
}
|
|
468
|
+
async shutdown(timeoutMs = this.clientConfig.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS) {
|
|
469
|
+
const effectiveTimeout = Math.max(0, timeoutMs);
|
|
470
|
+
for (const [serverName, state] of this.states) {
|
|
471
|
+
if (state.idleTimer) {
|
|
472
|
+
clearTimeout(state.idleTimer);
|
|
473
|
+
state.idleTimer = null;
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
if (state.transport.shutdown) {
|
|
477
|
+
await state.transport.shutdown(effectiveTimeout);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
await state.transport.disconnect();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
this.logger.warn(`[mcp-bridge] Router shutdown: failed to close ${serverName}:`, error);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
this.states.clear();
|
|
488
|
+
this.toolResolver.clear();
|
|
489
|
+
if (this.intentRouter) {
|
|
490
|
+
this.intentRouter.clearIndex();
|
|
491
|
+
}
|
|
492
|
+
this.resultCache?.invalidate();
|
|
493
|
+
this.tokenManager.clear();
|
|
494
|
+
}
|
|
398
495
|
async ensureConnected(server) {
|
|
399
496
|
let state = this.states.get(server);
|
|
400
497
|
if (!state) {
|
|
@@ -497,13 +594,13 @@ export class McpRouter {
|
|
|
497
594
|
this.resultCache?.invalidate(`${serverName}:`);
|
|
498
595
|
};
|
|
499
596
|
if (serverConfig.transport === "sse") {
|
|
500
|
-
return new this.transportRefs.sse(serverConfig, this.clientConfig, this.logger, onReconnected);
|
|
597
|
+
return new this.transportRefs.sse(serverConfig, this.clientConfig, this.logger, onReconnected, this.tokenManager);
|
|
501
598
|
}
|
|
502
599
|
if (serverConfig.transport === "stdio") {
|
|
503
600
|
return new this.transportRefs.stdio(serverConfig, this.clientConfig, this.logger, onReconnected);
|
|
504
601
|
}
|
|
505
602
|
if (serverConfig.transport === "streamable-http") {
|
|
506
|
-
return new this.transportRefs.streamableHttp(serverConfig, this.clientConfig, this.logger, onReconnected);
|
|
603
|
+
return new this.transportRefs.streamableHttp(serverConfig, this.clientConfig, this.logger, onReconnected, this.tokenManager);
|
|
507
604
|
}
|
|
508
605
|
throw new Error(`Unsupported transport: ${serverConfig.transport}`);
|
|
509
606
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Logger } from "./types.js";
|
|
2
|
+
export interface OAuth2Config {
|
|
3
|
+
clientId: string;
|
|
4
|
+
clientSecret: string;
|
|
5
|
+
tokenUrl: string;
|
|
6
|
+
scopes?: string[];
|
|
7
|
+
audience?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class OAuth2TokenManager {
|
|
10
|
+
private readonly logger;
|
|
11
|
+
private readonly tokenCache;
|
|
12
|
+
private readonly inflight;
|
|
13
|
+
constructor(logger: Logger);
|
|
14
|
+
getToken(config: OAuth2Config): Promise<string>;
|
|
15
|
+
invalidate(tokenUrl: string, clientId: string): void;
|
|
16
|
+
clear(): void;
|
|
17
|
+
private makeKey;
|
|
18
|
+
private fetchToken;
|
|
19
|
+
private exchangeToken;
|
|
20
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const DEFAULT_EXPIRES_IN_SECONDS = 3600;
|
|
2
|
+
const EXPIRY_BUFFER_SECONDS = 60;
|
|
3
|
+
export class OAuth2TokenManager {
|
|
4
|
+
logger;
|
|
5
|
+
tokenCache = new Map();
|
|
6
|
+
inflight = new Map();
|
|
7
|
+
constructor(logger) {
|
|
8
|
+
this.logger = logger;
|
|
9
|
+
}
|
|
10
|
+
async getToken(config) {
|
|
11
|
+
const key = this.makeKey(config.tokenUrl, config.clientId);
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
const cached = this.tokenCache.get(key);
|
|
14
|
+
if (cached && cached.expiresAt > now) {
|
|
15
|
+
return cached.accessToken;
|
|
16
|
+
}
|
|
17
|
+
const existingInflight = this.inflight.get(key);
|
|
18
|
+
if (existingInflight) {
|
|
19
|
+
return existingInflight;
|
|
20
|
+
}
|
|
21
|
+
const requestPromise = this.fetchToken(config, cached)
|
|
22
|
+
.then((token) => {
|
|
23
|
+
this.tokenCache.set(key, token);
|
|
24
|
+
return token.accessToken;
|
|
25
|
+
})
|
|
26
|
+
.finally(() => {
|
|
27
|
+
this.inflight.delete(key);
|
|
28
|
+
});
|
|
29
|
+
this.inflight.set(key, requestPromise);
|
|
30
|
+
return requestPromise;
|
|
31
|
+
}
|
|
32
|
+
invalidate(tokenUrl, clientId) {
|
|
33
|
+
const key = this.makeKey(tokenUrl, clientId);
|
|
34
|
+
this.tokenCache.delete(key);
|
|
35
|
+
this.inflight.delete(key);
|
|
36
|
+
}
|
|
37
|
+
clear() {
|
|
38
|
+
this.tokenCache.clear();
|
|
39
|
+
this.inflight.clear();
|
|
40
|
+
}
|
|
41
|
+
makeKey(tokenUrl, clientId) {
|
|
42
|
+
return `${tokenUrl}::${clientId}`;
|
|
43
|
+
}
|
|
44
|
+
async fetchToken(config, cached) {
|
|
45
|
+
if (cached?.refreshToken) {
|
|
46
|
+
try {
|
|
47
|
+
return await this.exchangeToken(config, {
|
|
48
|
+
grant_type: "refresh_token",
|
|
49
|
+
refresh_token: cached.refreshToken,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
this.logger.warn("[mcp-bridge] OAuth2 refresh token exchange failed, falling back to client_credentials:", error);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return this.exchangeToken(config, {
|
|
57
|
+
grant_type: "client_credentials",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async exchangeToken(config, grant) {
|
|
61
|
+
const formData = new URLSearchParams();
|
|
62
|
+
formData.set("grant_type", grant.grant_type);
|
|
63
|
+
formData.set("client_id", config.clientId);
|
|
64
|
+
formData.set("client_secret", config.clientSecret);
|
|
65
|
+
if (grant.grant_type === "refresh_token") {
|
|
66
|
+
formData.set("refresh_token", grant.refresh_token);
|
|
67
|
+
}
|
|
68
|
+
if (config.scopes?.length) {
|
|
69
|
+
formData.set("scope", config.scopes.join(" "));
|
|
70
|
+
}
|
|
71
|
+
if (config.audience) {
|
|
72
|
+
formData.set("audience", config.audience);
|
|
73
|
+
}
|
|
74
|
+
const response = await fetch(config.tokenUrl, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
78
|
+
},
|
|
79
|
+
body: formData.toString(),
|
|
80
|
+
});
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new Error(`OAuth2 token exchange failed: HTTP ${response.status}`);
|
|
83
|
+
}
|
|
84
|
+
const payload = (await response.json());
|
|
85
|
+
if (!payload.access_token) {
|
|
86
|
+
throw new Error("OAuth2 token exchange response missing access_token");
|
|
87
|
+
}
|
|
88
|
+
const expiresIn = Number.isFinite(payload.expires_in)
|
|
89
|
+
? Number(payload.expires_in)
|
|
90
|
+
: DEFAULT_EXPIRES_IN_SECONDS;
|
|
91
|
+
const expiresAt = Date.now() + Math.max(0, expiresIn - EXPIRY_BUFFER_SECONDS) * 1000;
|
|
92
|
+
return {
|
|
93
|
+
accessToken: payload.access_token,
|
|
94
|
+
expiresAt,
|
|
95
|
+
refreshToken: payload.refresh_token,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -4,6 +4,7 @@ import { pickRegisteredToolName } from "./tool-naming.js";
|
|
|
4
4
|
import { SseTransport } from "./transport-sse.js";
|
|
5
5
|
import { StdioTransport } from "./transport-stdio.js";
|
|
6
6
|
import { StreamableHttpTransport } from "./transport-streamable-http.js";
|
|
7
|
+
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
7
8
|
/**
|
|
8
9
|
* Standalone MCP server that wraps the router.
|
|
9
10
|
* Implements the MCP protocol (initialize, tools/list, tools/call)
|
|
@@ -15,12 +16,14 @@ export class StandaloneServer {
|
|
|
15
16
|
router = null;
|
|
16
17
|
initialized = false;
|
|
17
18
|
lspMode = false;
|
|
19
|
+
tokenManager;
|
|
18
20
|
// Direct mode state
|
|
19
21
|
directTools = [];
|
|
20
22
|
directConnections = new Map();
|
|
21
23
|
constructor(config, logger) {
|
|
22
24
|
this.config = config;
|
|
23
25
|
this.logger = logger;
|
|
26
|
+
this.tokenManager = new OAuth2TokenManager(logger);
|
|
24
27
|
if (this.isRouterMode()) {
|
|
25
28
|
this.router = new McpRouter(config.servers || {}, config, logger);
|
|
26
29
|
}
|
|
@@ -400,11 +403,11 @@ export class StandaloneServer {
|
|
|
400
403
|
};
|
|
401
404
|
switch (serverConfig.transport) {
|
|
402
405
|
case "sse":
|
|
403
|
-
return new SseTransport(serverConfig, this.config, this.logger, onReconnected);
|
|
406
|
+
return new SseTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager);
|
|
404
407
|
case "stdio":
|
|
405
408
|
return new StdioTransport(serverConfig, this.config, this.logger, onReconnected);
|
|
406
409
|
case "streamable-http":
|
|
407
|
-
return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected);
|
|
410
|
+
return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager);
|
|
408
411
|
default:
|
|
409
412
|
throw new Error(`Unsupported transport: ${serverConfig.transport}`);
|
|
410
413
|
}
|
|
@@ -413,7 +416,7 @@ export class StandaloneServer {
|
|
|
413
416
|
async shutdown() {
|
|
414
417
|
this.logger.info("[mcp-bridge] Shutting down...");
|
|
415
418
|
if (this.router) {
|
|
416
|
-
await this.router.
|
|
419
|
+
await this.router.shutdown(this.config.shutdownTimeoutMs);
|
|
417
420
|
}
|
|
418
421
|
for (const [name, conn] of this.directConnections) {
|
|
419
422
|
try {
|
|
@@ -424,6 +427,7 @@ export class StandaloneServer {
|
|
|
424
427
|
}
|
|
425
428
|
}
|
|
426
429
|
this.directConnections.clear();
|
|
430
|
+
this.tokenManager.clear();
|
|
427
431
|
this.logger.info("[mcp-bridge] Shutdown complete");
|
|
428
432
|
}
|
|
429
433
|
}
|
|
@@ -26,6 +26,7 @@ export declare class ToolResolver {
|
|
|
26
26
|
resolve(toolName: string, params?: Record<string, unknown>, serverHint?: string): ToolResolutionResult;
|
|
27
27
|
recordCall(server: string, tool: string): void;
|
|
28
28
|
getKnownToolNames(): string[];
|
|
29
|
+
clear(): void;
|
|
29
30
|
private scoreCandidate;
|
|
30
31
|
private wasUsedRecently;
|
|
31
32
|
private computeParamMatch;
|
|
@@ -99,6 +99,11 @@ export class ToolResolver {
|
|
|
99
99
|
getKnownToolNames() {
|
|
100
100
|
return [...this.toolsByName.keys()];
|
|
101
101
|
}
|
|
102
|
+
clear() {
|
|
103
|
+
this.toolsByName.clear();
|
|
104
|
+
this.toolNamesByServer.clear();
|
|
105
|
+
this.recentCalls.length = 0;
|
|
106
|
+
}
|
|
102
107
|
scoreCandidate(server, inputSchema, params) {
|
|
103
108
|
const base = this.basePriority.get(server) ?? BASE_PRIORITY_MIN;
|
|
104
109
|
const recency = this.wasUsedRecently(server) ? RECENCY_BOOST : 0;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpTransport, McpRequest, McpResponse, McpServerConfig, McpClientConfig, Logger, JsonRpcMessage } from "./types.js";
|
|
2
|
+
import type { OAuth2Config, OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
2
3
|
export type PendingRequest = {
|
|
3
4
|
resolve: (value: McpResponse) => void;
|
|
4
5
|
reject: (reason: Error) => void;
|
|
@@ -70,6 +71,17 @@ export declare function resolveEnvRecord(record: Record<string, string>, context
|
|
|
70
71
|
* @param extraEnv - Additional env vars to check before process.env
|
|
71
72
|
*/
|
|
72
73
|
export declare function resolveArgs(args: string[], extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): string[];
|
|
74
|
+
/**
|
|
75
|
+
* Resolve auth config into HTTP headers.
|
|
76
|
+
*/
|
|
77
|
+
export declare function resolveAuthHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
|
|
78
|
+
export declare function resolveOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): OAuth2Config;
|
|
79
|
+
export declare function resolveAuthHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Promise<Record<string, string>>;
|
|
80
|
+
/**
|
|
81
|
+
* Resolve server headers and merge auth headers (auth takes precedence).
|
|
82
|
+
*/
|
|
83
|
+
export declare function resolveServerHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
|
|
84
|
+
export declare function resolveServerHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Promise<Record<string, string>>;
|
|
73
85
|
/**
|
|
74
86
|
* Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
|
|
75
87
|
*/
|
|
@@ -159,6 +159,59 @@ export function resolveEnvRecord(record, contextPrefix, extraEnv, envFallback) {
|
|
|
159
159
|
export function resolveArgs(args, extraEnv, envFallback) {
|
|
160
160
|
return args.map(arg => resolveEnvVars(arg, `arg "${arg}"`, extraEnv, envFallback));
|
|
161
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolve auth config into HTTP headers.
|
|
164
|
+
*/
|
|
165
|
+
export function resolveAuthHeaders(config, extraEnv, envFallback) {
|
|
166
|
+
if (!config.auth)
|
|
167
|
+
return {};
|
|
168
|
+
if (config.auth.type === "bearer") {
|
|
169
|
+
const token = resolveEnvVars(config.auth.token, "auth token", extraEnv, envFallback);
|
|
170
|
+
return { Authorization: `Bearer ${token}` };
|
|
171
|
+
}
|
|
172
|
+
if (config.auth.type === "header") {
|
|
173
|
+
return resolveEnvRecord(config.auth.headers, "auth header", extraEnv, envFallback);
|
|
174
|
+
}
|
|
175
|
+
throw new Error("[mcp-bridge] OAuth2 auth requires async header resolution via resolveAuthHeadersAsync");
|
|
176
|
+
}
|
|
177
|
+
export function resolveOAuth2Config(config, extraEnv, envFallback) {
|
|
178
|
+
if (!config.auth || config.auth.type !== "oauth2") {
|
|
179
|
+
throw new Error("[mcp-bridge] resolveOAuth2Config called for non-oauth2 auth config");
|
|
180
|
+
}
|
|
181
|
+
const scopes = config.auth.scopes?.map((scope, index) => resolveEnvVars(scope, `oauth2 scope[${index}]`, extraEnv, envFallback));
|
|
182
|
+
return {
|
|
183
|
+
clientId: resolveEnvVars(config.auth.clientId, "oauth2 clientId", extraEnv, envFallback),
|
|
184
|
+
clientSecret: resolveEnvVars(config.auth.clientSecret, "oauth2 clientSecret", extraEnv, envFallback),
|
|
185
|
+
tokenUrl: resolveEnvVars(config.auth.tokenUrl, "oauth2 tokenUrl", extraEnv, envFallback),
|
|
186
|
+
...(scopes && scopes.length > 0 ? { scopes } : {}),
|
|
187
|
+
...(config.auth.audience
|
|
188
|
+
? { audience: resolveEnvVars(config.auth.audience, "oauth2 audience", extraEnv, envFallback) }
|
|
189
|
+
: {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
export async function resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback) {
|
|
193
|
+
if (!config.auth)
|
|
194
|
+
return {};
|
|
195
|
+
if (config.auth.type === "oauth2") {
|
|
196
|
+
const oauth2Config = resolveOAuth2Config(config, extraEnv, envFallback);
|
|
197
|
+
const token = await tokenManager.getToken(oauth2Config);
|
|
198
|
+
return { Authorization: `Bearer ${token}` };
|
|
199
|
+
}
|
|
200
|
+
return resolveAuthHeaders(config, extraEnv, envFallback);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Resolve server headers and merge auth headers (auth takes precedence).
|
|
204
|
+
*/
|
|
205
|
+
export function resolveServerHeaders(config, extraEnv, envFallback) {
|
|
206
|
+
const base = resolveEnvRecord(config.headers || {}, "header", extraEnv, envFallback);
|
|
207
|
+
const auth = resolveAuthHeaders(config, extraEnv, envFallback);
|
|
208
|
+
return { ...base, ...auth };
|
|
209
|
+
}
|
|
210
|
+
export async function resolveServerHeadersAsync(config, tokenManager, extraEnv, envFallback) {
|
|
211
|
+
const base = resolveEnvRecord(config.headers || {}, "header", extraEnv, envFallback);
|
|
212
|
+
const auth = await resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback);
|
|
213
|
+
return { ...base, ...auth };
|
|
214
|
+
}
|
|
162
215
|
/**
|
|
163
216
|
* Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
|
|
164
217
|
*/
|
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
import { McpRequest, McpResponse } from "./types.js";
|
|
1
|
+
import { Logger, McpClientConfig, McpRequest, McpResponse, McpServerConfig } from "./types.js";
|
|
2
|
+
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
2
3
|
import { BaseTransport } from "./transport-base.js";
|
|
3
4
|
export declare class SseTransport extends BaseTransport {
|
|
4
5
|
private endpointUrl;
|
|
5
6
|
private sseAbortController;
|
|
6
7
|
private resolvedHeaders;
|
|
8
|
+
private pendingRequestControllers;
|
|
9
|
+
private readonly tokenManager;
|
|
7
10
|
protected get transportName(): string;
|
|
11
|
+
constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager);
|
|
8
12
|
connect(): Promise<void>;
|
|
9
13
|
private _onEndpointReceived;
|
|
14
|
+
private getBaseHeaders;
|
|
15
|
+
private refreshResolvedHeaders;
|
|
16
|
+
private invalidateOAuth2Token;
|
|
17
|
+
private fetchWithOAuthRetry;
|
|
10
18
|
private startEventStream;
|
|
11
19
|
private processEventLine;
|
|
12
20
|
sendNotification(notification: any): Promise<void>;
|
|
13
21
|
sendRequest(request: McpRequest): Promise<McpResponse>;
|
|
14
22
|
private isSameOrigin;
|
|
15
23
|
disconnect(): Promise<void>;
|
|
24
|
+
shutdown(): Promise<void>;
|
|
16
25
|
}
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { nextRequestId } from "./types.js";
|
|
2
|
-
import {
|
|
2
|
+
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
3
|
+
import { BaseTransport, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
|
|
3
4
|
export class SseTransport extends BaseTransport {
|
|
4
5
|
endpointUrl = null;
|
|
5
6
|
sseAbortController = null;
|
|
6
7
|
resolvedHeaders = null;
|
|
8
|
+
pendingRequestControllers = new Map();
|
|
9
|
+
tokenManager;
|
|
7
10
|
get transportName() { return "SSE"; }
|
|
11
|
+
constructor(config, clientConfig, logger, onReconnected, tokenManager) {
|
|
12
|
+
super(config, clientConfig, logger, onReconnected);
|
|
13
|
+
this.tokenManager = tokenManager ?? new OAuth2TokenManager(logger);
|
|
14
|
+
}
|
|
8
15
|
async connect() {
|
|
9
16
|
if (!this.config.url) {
|
|
10
17
|
throw new Error("SSE transport requires URL");
|
|
11
18
|
}
|
|
12
19
|
warnIfNonTlsRemoteUrl(this.config.url, this.logger);
|
|
13
|
-
|
|
14
|
-
this.resolvedHeaders = resolveEnvRecord(this.config.headers || {}, "header");
|
|
20
|
+
await this.refreshResolvedHeaders();
|
|
15
21
|
if (this.sseAbortController) {
|
|
16
22
|
this.sseAbortController.abort();
|
|
17
23
|
}
|
|
@@ -23,7 +29,7 @@ export class SseTransport extends BaseTransport {
|
|
|
23
29
|
});
|
|
24
30
|
// Fire and forget the stream reader
|
|
25
31
|
this.startEventStream().catch((error) => {
|
|
26
|
-
if (error instanceof Error && error.name !==
|
|
32
|
+
if (error instanceof Error && error.name !== "AbortError") {
|
|
27
33
|
this.logger.error("[mcp-bridge] SSE stream error:", error.message);
|
|
28
34
|
this.scheduleReconnect();
|
|
29
35
|
}
|
|
@@ -33,16 +39,58 @@ export class SseTransport extends BaseTransport {
|
|
|
33
39
|
this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
|
|
34
40
|
}
|
|
35
41
|
_onEndpointReceived = null;
|
|
42
|
+
async getBaseHeaders(forceRefresh = false) {
|
|
43
|
+
if (this.config.auth?.type === "oauth2") {
|
|
44
|
+
if (forceRefresh) {
|
|
45
|
+
this.invalidateOAuth2Token();
|
|
46
|
+
}
|
|
47
|
+
return this.refreshResolvedHeaders();
|
|
48
|
+
}
|
|
49
|
+
if (!this.resolvedHeaders) {
|
|
50
|
+
this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
|
|
51
|
+
}
|
|
52
|
+
return this.resolvedHeaders;
|
|
53
|
+
}
|
|
54
|
+
async refreshResolvedHeaders() {
|
|
55
|
+
if (this.config.auth?.type === "oauth2") {
|
|
56
|
+
this.resolvedHeaders = await resolveServerHeadersAsync(this.config, this.tokenManager, undefined, this.clientConfig.envFallback);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
|
|
60
|
+
}
|
|
61
|
+
return this.resolvedHeaders;
|
|
62
|
+
}
|
|
63
|
+
invalidateOAuth2Token() {
|
|
64
|
+
if (this.config.auth?.type !== "oauth2") {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const oauth2Config = resolveOAuth2Config(this.config, undefined, this.clientConfig.envFallback);
|
|
68
|
+
this.tokenManager.invalidate(oauth2Config.tokenUrl, oauth2Config.clientId);
|
|
69
|
+
}
|
|
70
|
+
async fetchWithOAuthRetry(url, init) {
|
|
71
|
+
const response = await fetch(url, init);
|
|
72
|
+
if (response.status !== 401 || this.config.auth?.type !== "oauth2") {
|
|
73
|
+
return response;
|
|
74
|
+
}
|
|
75
|
+
this.logger.warn("[mcp-bridge] SSE request returned 401, invalidating OAuth2 token and retrying once");
|
|
76
|
+
const refreshedBase = await this.getBaseHeaders(true);
|
|
77
|
+
const retryHeaders = {
|
|
78
|
+
...refreshedBase,
|
|
79
|
+
...Object.fromEntries(Array.from(new Headers(init.headers).entries()).filter(([key]) => key.toLowerCase() !== "authorization")),
|
|
80
|
+
Authorization: refreshedBase.Authorization,
|
|
81
|
+
};
|
|
82
|
+
return fetch(url, { ...init, headers: retryHeaders });
|
|
83
|
+
}
|
|
36
84
|
async startEventStream() {
|
|
37
85
|
if (!this.config.url)
|
|
38
86
|
return;
|
|
39
|
-
const base = this.
|
|
40
|
-
const headers = { ...base,
|
|
87
|
+
const base = await this.getBaseHeaders();
|
|
88
|
+
const headers = { ...base, Accept: "text/event-stream" };
|
|
41
89
|
try {
|
|
42
|
-
const response = await
|
|
90
|
+
const response = await this.fetchWithOAuthRetry(this.config.url, {
|
|
43
91
|
method: "GET",
|
|
44
92
|
headers,
|
|
45
|
-
signal: this.sseAbortController?.signal
|
|
93
|
+
signal: this.sseAbortController?.signal,
|
|
46
94
|
});
|
|
47
95
|
if (!response.ok) {
|
|
48
96
|
throw new Error(`SSE connection failed: HTTP ${response.status}`);
|
|
@@ -59,7 +107,7 @@ export class SseTransport extends BaseTransport {
|
|
|
59
107
|
if (done)
|
|
60
108
|
break;
|
|
61
109
|
buffer += decoder.decode(value, { stream: true });
|
|
62
|
-
const lines = buffer.split(
|
|
110
|
+
const lines = buffer.split("\n");
|
|
63
111
|
buffer = lines.pop() || "";
|
|
64
112
|
for (const line of lines) {
|
|
65
113
|
this.processEventLine(line, state);
|
|
@@ -70,7 +118,7 @@ export class SseTransport extends BaseTransport {
|
|
|
70
118
|
this.scheduleReconnect();
|
|
71
119
|
}
|
|
72
120
|
catch (error) {
|
|
73
|
-
if (error instanceof Error && error.name ===
|
|
121
|
+
if (error instanceof Error && error.name === "AbortError")
|
|
74
122
|
return;
|
|
75
123
|
this.logger.error("SSE stream error:", error);
|
|
76
124
|
this.scheduleReconnect();
|
|
@@ -130,12 +178,12 @@ export class SseTransport extends BaseTransport {
|
|
|
130
178
|
if (!this.connected || !this.endpointUrl) {
|
|
131
179
|
throw new Error("SSE transport not connected or no endpoint URL");
|
|
132
180
|
}
|
|
133
|
-
const base = this.
|
|
181
|
+
const base = await this.getBaseHeaders();
|
|
134
182
|
const headers = { ...base, "Content-Type": "application/json" };
|
|
135
|
-
const response = await
|
|
183
|
+
const response = await this.fetchWithOAuthRetry(this.endpointUrl, {
|
|
136
184
|
method: "POST",
|
|
137
185
|
headers,
|
|
138
|
-
body: JSON.stringify(notification)
|
|
186
|
+
body: JSON.stringify(notification),
|
|
139
187
|
});
|
|
140
188
|
if (!response.ok) {
|
|
141
189
|
this.logger.warn(`[mcp-bridge] SSE notification got HTTP ${response.status}`);
|
|
@@ -150,32 +198,41 @@ export class SseTransport extends BaseTransport {
|
|
|
150
198
|
return new Promise((resolve, reject) => {
|
|
151
199
|
const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
|
|
152
200
|
const timeout = setTimeout(() => {
|
|
201
|
+
this.pendingRequestControllers.get(id)?.abort();
|
|
202
|
+
this.pendingRequestControllers.delete(id);
|
|
153
203
|
this.pendingRequests.delete(id);
|
|
154
204
|
reject(new Error(`Request timeout after ${requestTimeout}ms`));
|
|
155
205
|
}, requestTimeout);
|
|
156
206
|
this.pendingRequests.set(id, { resolve, reject, timeout });
|
|
157
|
-
const
|
|
158
|
-
|
|
207
|
+
const abortController = new AbortController();
|
|
208
|
+
this.pendingRequestControllers.set(id, abortController);
|
|
159
209
|
// The response arrives via the SSE stream (handleMessage), not from this fetch.
|
|
160
210
|
// The fetch only confirms the server accepted the request (HTTP 200).
|
|
161
211
|
// If the fetch fails, we reject immediately; otherwise we wait for the SSE stream.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
212
|
+
(async () => {
|
|
213
|
+
try {
|
|
214
|
+
const base = await this.getBaseHeaders();
|
|
215
|
+
const headers = { ...base, "Content-Type": "application/json" };
|
|
216
|
+
const response = await this.fetchWithOAuthRetry(this.endpointUrl, {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers,
|
|
219
|
+
body: JSON.stringify(requestWithId),
|
|
220
|
+
signal: abortController.signal,
|
|
221
|
+
});
|
|
222
|
+
this.pendingRequestControllers.delete(id);
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
clearTimeout(timeout);
|
|
225
|
+
this.pendingRequests.delete(id);
|
|
226
|
+
reject(new Error(`HTTP ${response.status}`));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
this.pendingRequestControllers.delete(id);
|
|
169
231
|
clearTimeout(timeout);
|
|
170
232
|
this.pendingRequests.delete(id);
|
|
171
|
-
reject(
|
|
233
|
+
reject(error);
|
|
172
234
|
}
|
|
173
|
-
})
|
|
174
|
-
.catch((error) => {
|
|
175
|
-
clearTimeout(timeout);
|
|
176
|
-
this.pendingRequests.delete(id);
|
|
177
|
-
reject(error);
|
|
178
|
-
});
|
|
235
|
+
})();
|
|
179
236
|
});
|
|
180
237
|
}
|
|
181
238
|
isSameOrigin(url) {
|
|
@@ -197,6 +254,13 @@ export class SseTransport extends BaseTransport {
|
|
|
197
254
|
this.sseAbortController.abort();
|
|
198
255
|
this.sseAbortController = null;
|
|
199
256
|
}
|
|
257
|
+
for (const [, controller] of this.pendingRequestControllers) {
|
|
258
|
+
controller.abort();
|
|
259
|
+
}
|
|
260
|
+
this.pendingRequestControllers.clear();
|
|
200
261
|
this.rejectAllPending("Connection closed");
|
|
201
262
|
}
|
|
263
|
+
async shutdown() {
|
|
264
|
+
await this.disconnect();
|
|
265
|
+
}
|
|
202
266
|
}
|
|
@@ -15,6 +15,7 @@ export declare class StdioTransport extends BaseTransport {
|
|
|
15
15
|
private parseNewlineMessageFromBuffer;
|
|
16
16
|
private parseLspMessageFromBuffer;
|
|
17
17
|
disconnect(): Promise<void>;
|
|
18
|
+
shutdown(timeoutMs?: number): Promise<void>;
|
|
18
19
|
isConnected(): boolean;
|
|
19
20
|
private terminateProcessGracefully;
|
|
20
21
|
}
|
|
@@ -264,7 +264,20 @@ export class StdioTransport extends BaseTransport {
|
|
|
264
264
|
this.logger.debug("[mcp-bridge] Failed to send close notification during stdio disconnect");
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
|
-
await this.terminateProcessGracefully(activeProcess);
|
|
267
|
+
await this.terminateProcessGracefully(activeProcess, this.clientConfig.shutdownTimeoutMs ?? 5000);
|
|
268
|
+
if (this.process === activeProcess) {
|
|
269
|
+
this.process = null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
this.rejectAllPending("Connection closed");
|
|
273
|
+
}
|
|
274
|
+
async shutdown(timeoutMs = this.clientConfig.shutdownTimeoutMs ?? 5000) {
|
|
275
|
+
this.isShuttingDown = true;
|
|
276
|
+
this.connected = false;
|
|
277
|
+
this.cleanupReconnectTimer();
|
|
278
|
+
const activeProcess = this.process;
|
|
279
|
+
if (activeProcess) {
|
|
280
|
+
await this.terminateProcessGracefully(activeProcess, timeoutMs);
|
|
268
281
|
if (this.process === activeProcess) {
|
|
269
282
|
this.process = null;
|
|
270
283
|
}
|
|
@@ -274,8 +287,8 @@ export class StdioTransport extends BaseTransport {
|
|
|
274
287
|
isConnected() {
|
|
275
288
|
return this.connected && this.process !== null;
|
|
276
289
|
}
|
|
277
|
-
async terminateProcessGracefully(proc) {
|
|
278
|
-
if (proc.exitCode !== null
|
|
290
|
+
async terminateProcessGracefully(proc, timeoutMs) {
|
|
291
|
+
if (proc.exitCode !== null)
|
|
279
292
|
return;
|
|
280
293
|
await new Promise((resolve) => {
|
|
281
294
|
let done = false;
|
|
@@ -292,21 +305,20 @@ export class StdioTransport extends BaseTransport {
|
|
|
292
305
|
const onExit = () => finish();
|
|
293
306
|
proc.once("exit", onExit);
|
|
294
307
|
try {
|
|
295
|
-
proc.kill("
|
|
308
|
+
proc.kill("SIGTERM");
|
|
296
309
|
}
|
|
297
310
|
catch {
|
|
298
311
|
finish();
|
|
299
312
|
return;
|
|
300
313
|
}
|
|
301
314
|
forceKillTimer = setTimeout(() => {
|
|
302
|
-
if (proc.exitCode === null
|
|
315
|
+
if (proc.exitCode === null) {
|
|
303
316
|
try {
|
|
304
|
-
proc.kill("
|
|
317
|
+
proc.kill("SIGKILL");
|
|
305
318
|
}
|
|
306
319
|
catch { /* ignore */ }
|
|
307
320
|
}
|
|
308
|
-
|
|
309
|
-
}, 2000);
|
|
321
|
+
}, Math.max(0, timeoutMs));
|
|
310
322
|
});
|
|
311
323
|
}
|
|
312
324
|
}
|
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
import { McpRequest, McpResponse } from "./types.js";
|
|
1
|
+
import { Logger, McpClientConfig, McpRequest, McpResponse, McpServerConfig } from "./types.js";
|
|
2
|
+
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
2
3
|
import { BaseTransport } from "./transport-base.js";
|
|
3
4
|
export declare class StreamableHttpTransport extends BaseTransport {
|
|
4
5
|
private sessionId?;
|
|
6
|
+
private resolvedHeaders;
|
|
7
|
+
private pendingRequestControllers;
|
|
8
|
+
private readonly tokenManager;
|
|
5
9
|
protected get transportName(): string;
|
|
10
|
+
constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager);
|
|
6
11
|
connect(): Promise<void>;
|
|
12
|
+
private getBaseHeaders;
|
|
13
|
+
private refreshResolvedHeaders;
|
|
14
|
+
private invalidateOAuth2Token;
|
|
15
|
+
private fetchWithOAuthRetry;
|
|
7
16
|
sendRequest(request: McpRequest): Promise<McpResponse>;
|
|
8
17
|
sendNotification(notification: any): Promise<void>;
|
|
9
18
|
private probeServer;
|
|
10
19
|
disconnect(): Promise<void>;
|
|
20
|
+
shutdown(): Promise<void>;
|
|
11
21
|
}
|
|
@@ -1,20 +1,69 @@
|
|
|
1
1
|
import { nextRequestId } from "./types.js";
|
|
2
|
-
import {
|
|
2
|
+
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
3
|
+
import { BaseTransport, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
|
|
3
4
|
export class StreamableHttpTransport extends BaseTransport {
|
|
4
5
|
sessionId;
|
|
6
|
+
resolvedHeaders = null;
|
|
7
|
+
pendingRequestControllers = new Map();
|
|
8
|
+
tokenManager;
|
|
5
9
|
get transportName() { return "streamable-http"; }
|
|
10
|
+
constructor(config, clientConfig, logger, onReconnected, tokenManager) {
|
|
11
|
+
super(config, clientConfig, logger, onReconnected);
|
|
12
|
+
this.tokenManager = tokenManager ?? new OAuth2TokenManager(logger);
|
|
13
|
+
}
|
|
6
14
|
async connect() {
|
|
7
15
|
if (!this.config.url) {
|
|
8
16
|
throw new Error("Streamable HTTP transport requires URL");
|
|
9
17
|
}
|
|
10
18
|
warnIfNonTlsRemoteUrl(this.config.url, this.logger);
|
|
11
|
-
|
|
12
|
-
resolveEnvRecord(this.config.headers || {}, "header");
|
|
19
|
+
await this.refreshResolvedHeaders();
|
|
13
20
|
await this.probeServer();
|
|
14
21
|
this.connected = true;
|
|
15
22
|
this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
|
|
16
23
|
this.logger.info(`[mcp-bridge] Streamable HTTP transport ready for ${this.config.url}`);
|
|
17
24
|
}
|
|
25
|
+
async getBaseHeaders(forceRefresh = false) {
|
|
26
|
+
if (this.config.auth?.type === "oauth2") {
|
|
27
|
+
if (forceRefresh) {
|
|
28
|
+
this.invalidateOAuth2Token();
|
|
29
|
+
}
|
|
30
|
+
return this.refreshResolvedHeaders();
|
|
31
|
+
}
|
|
32
|
+
if (!this.resolvedHeaders) {
|
|
33
|
+
this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
|
|
34
|
+
}
|
|
35
|
+
return this.resolvedHeaders;
|
|
36
|
+
}
|
|
37
|
+
async refreshResolvedHeaders() {
|
|
38
|
+
if (this.config.auth?.type === "oauth2") {
|
|
39
|
+
this.resolvedHeaders = await resolveServerHeadersAsync(this.config, this.tokenManager, undefined, this.clientConfig.envFallback);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
|
|
43
|
+
}
|
|
44
|
+
return this.resolvedHeaders;
|
|
45
|
+
}
|
|
46
|
+
invalidateOAuth2Token() {
|
|
47
|
+
if (this.config.auth?.type !== "oauth2") {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const oauth2Config = resolveOAuth2Config(this.config, undefined, this.clientConfig.envFallback);
|
|
51
|
+
this.tokenManager.invalidate(oauth2Config.tokenUrl, oauth2Config.clientId);
|
|
52
|
+
}
|
|
53
|
+
async fetchWithOAuthRetry(url, init) {
|
|
54
|
+
const response = await fetch(url, init);
|
|
55
|
+
if (response.status !== 401 || this.config.auth?.type !== "oauth2") {
|
|
56
|
+
return response;
|
|
57
|
+
}
|
|
58
|
+
this.logger.warn("[mcp-bridge] Streamable HTTP request returned 401, invalidating OAuth2 token and retrying once");
|
|
59
|
+
const refreshedBase = await this.getBaseHeaders(true);
|
|
60
|
+
const retryHeaders = {
|
|
61
|
+
...refreshedBase,
|
|
62
|
+
...Object.fromEntries(Array.from(new Headers(init.headers).entries()).filter(([key]) => key.toLowerCase() !== "authorization")),
|
|
63
|
+
Authorization: refreshedBase.Authorization,
|
|
64
|
+
};
|
|
65
|
+
return fetch(url, { ...init, headers: retryHeaders });
|
|
66
|
+
}
|
|
18
67
|
async sendRequest(request) {
|
|
19
68
|
if (!this.connected || !this.config.url) {
|
|
20
69
|
throw new Error("Streamable HTTP transport not connected");
|
|
@@ -23,108 +72,120 @@ export class StreamableHttpTransport extends BaseTransport {
|
|
|
23
72
|
const requestWithId = { ...request, id };
|
|
24
73
|
return new Promise((resolve, reject) => {
|
|
25
74
|
const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
|
|
75
|
+
const abortController = new AbortController();
|
|
26
76
|
const timeout = setTimeout(() => {
|
|
77
|
+
abortController.abort();
|
|
78
|
+
this.pendingRequestControllers.delete(id);
|
|
27
79
|
this.pendingRequests.delete(id);
|
|
28
80
|
reject(new Error(`Request timeout after ${requestTimeout}ms`));
|
|
29
81
|
}, requestTimeout);
|
|
30
82
|
this.pendingRequests.set(id, { resolve, reject, timeout });
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
...this.config.headers,
|
|
34
|
-
"Content-Type": "application/json"
|
|
35
|
-
}, "header");
|
|
36
|
-
if (this.sessionId) {
|
|
37
|
-
headers["mcp-session-id"] = this.sessionId;
|
|
38
|
-
}
|
|
39
|
-
fetch(this.config.url, {
|
|
40
|
-
method: "POST",
|
|
41
|
-
headers,
|
|
42
|
-
body: JSON.stringify(requestWithId)
|
|
43
|
-
})
|
|
44
|
-
.then(async (response) => {
|
|
45
|
-
const responseSessionId = response.headers.get("mcp-session-id");
|
|
46
|
-
if (responseSessionId) {
|
|
47
|
-
this.sessionId = responseSessionId;
|
|
48
|
-
}
|
|
49
|
-
if (!response.ok) {
|
|
50
|
-
clearTimeout(timeout);
|
|
51
|
-
this.pendingRequests.delete(id);
|
|
52
|
-
reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
83
|
+
this.pendingRequestControllers.set(id, abortController);
|
|
84
|
+
(async () => {
|
|
55
85
|
try {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
const base = await this.getBaseHeaders();
|
|
87
|
+
const headers = {
|
|
88
|
+
...base,
|
|
89
|
+
Accept: "application/json, text/event-stream",
|
|
90
|
+
"Content-Type": "application/json",
|
|
91
|
+
};
|
|
92
|
+
if (this.sessionId) {
|
|
93
|
+
headers["mcp-session-id"] = this.sessionId;
|
|
94
|
+
}
|
|
95
|
+
const response = await this.fetchWithOAuthRetry(this.config.url, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers,
|
|
98
|
+
body: JSON.stringify(requestWithId),
|
|
99
|
+
signal: abortController.signal,
|
|
100
|
+
});
|
|
101
|
+
this.pendingRequestControllers.delete(id);
|
|
102
|
+
const responseSessionId = response.headers.get("mcp-session-id");
|
|
103
|
+
if (responseSessionId) {
|
|
104
|
+
this.sessionId = responseSessionId;
|
|
105
|
+
}
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
clearTimeout(timeout);
|
|
108
|
+
this.pendingRequests.delete(id);
|
|
109
|
+
reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const contentType = response.headers.get("content-type") || "";
|
|
114
|
+
if (contentType.includes("text/event-stream")) {
|
|
115
|
+
const text = await response.text();
|
|
116
|
+
const lines = text.split("\n");
|
|
117
|
+
// SSE event boundary parsing: collect data lines, dispatch on empty line
|
|
118
|
+
let dataBuffer = [];
|
|
119
|
+
const dispatch = () => {
|
|
120
|
+
if (dataBuffer.length === 0)
|
|
121
|
+
return;
|
|
122
|
+
const data = dataBuffer.join("\n");
|
|
123
|
+
dataBuffer = [];
|
|
124
|
+
try {
|
|
125
|
+
this.handleMessage(JSON.parse(data));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// skip malformed events
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
let hasData = false;
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
const trimmed = line.trim();
|
|
134
|
+
if (trimmed.startsWith("data:")) {
|
|
135
|
+
dataBuffer.push(trimmed.substring(5).trimStart());
|
|
136
|
+
hasData = true;
|
|
137
|
+
}
|
|
138
|
+
else if (trimmed === "" && dataBuffer.length > 0) {
|
|
139
|
+
dispatch();
|
|
140
|
+
}
|
|
79
141
|
}
|
|
80
|
-
|
|
81
|
-
|
|
142
|
+
// Dispatch any trailing data (server may omit final empty line)
|
|
143
|
+
dispatch();
|
|
144
|
+
if (!hasData) {
|
|
145
|
+
throw new Error("No data lines in SSE response");
|
|
82
146
|
}
|
|
83
147
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!hasData) {
|
|
87
|
-
throw new Error("No data lines in SSE response");
|
|
148
|
+
else {
|
|
149
|
+
this.handleMessage(await response.json());
|
|
88
150
|
}
|
|
89
151
|
}
|
|
90
|
-
|
|
91
|
-
|
|
152
|
+
catch (error) {
|
|
153
|
+
clearTimeout(timeout);
|
|
154
|
+
this.pendingRequests.delete(id);
|
|
155
|
+
reject(new Error("Failed to parse response: " + (error instanceof Error ? error.message : String(error))));
|
|
92
156
|
}
|
|
93
157
|
}
|
|
94
158
|
catch (error) {
|
|
159
|
+
this.pendingRequestControllers.delete(id);
|
|
95
160
|
clearTimeout(timeout);
|
|
96
161
|
this.pendingRequests.delete(id);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
this.pendingRequests.delete(id);
|
|
103
|
-
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
|
104
|
-
this.logger.error("Connection error, scheduling reconnect:", error.message);
|
|
105
|
-
this.scheduleReconnect();
|
|
162
|
+
if (error instanceof Error && error.name === "TypeError" && error.message.includes("fetch")) {
|
|
163
|
+
this.logger.error("Connection error, scheduling reconnect:", error.message);
|
|
164
|
+
this.scheduleReconnect();
|
|
165
|
+
}
|
|
166
|
+
reject(error);
|
|
106
167
|
}
|
|
107
|
-
|
|
108
|
-
});
|
|
168
|
+
})();
|
|
109
169
|
});
|
|
110
170
|
}
|
|
111
171
|
async sendNotification(notification) {
|
|
112
172
|
if (!this.connected || !this.config.url) {
|
|
113
173
|
throw new Error("Streamable HTTP transport not connected");
|
|
114
174
|
}
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
...
|
|
118
|
-
|
|
119
|
-
|
|
175
|
+
const base = await this.getBaseHeaders();
|
|
176
|
+
const headers = {
|
|
177
|
+
...base,
|
|
178
|
+
Accept: "application/json, text/event-stream",
|
|
179
|
+
"Content-Type": "application/json",
|
|
180
|
+
};
|
|
120
181
|
if (this.sessionId) {
|
|
121
182
|
headers["mcp-session-id"] = this.sessionId;
|
|
122
183
|
}
|
|
123
184
|
try {
|
|
124
|
-
const response = await
|
|
185
|
+
const response = await this.fetchWithOAuthRetry(this.config.url, {
|
|
125
186
|
method: "POST",
|
|
126
187
|
headers,
|
|
127
|
-
body: JSON.stringify(notification)
|
|
188
|
+
body: JSON.stringify(notification),
|
|
128
189
|
});
|
|
129
190
|
const responseSessionId = response.headers.get("mcp-session-id");
|
|
130
191
|
if (responseSessionId) {
|
|
@@ -135,7 +196,7 @@ export class StreamableHttpTransport extends BaseTransport {
|
|
|
135
196
|
}
|
|
136
197
|
}
|
|
137
198
|
catch (error) {
|
|
138
|
-
if (error instanceof Error && error.name ===
|
|
199
|
+
if (error instanceof Error && error.name === "TypeError" && error.message.includes("fetch")) {
|
|
139
200
|
this.logger.error("Connection error during notification, scheduling reconnect:", error.message);
|
|
140
201
|
this.scheduleReconnect();
|
|
141
202
|
}
|
|
@@ -146,11 +207,11 @@ export class StreamableHttpTransport extends BaseTransport {
|
|
|
146
207
|
if (!this.config.url)
|
|
147
208
|
return;
|
|
148
209
|
try {
|
|
149
|
-
const
|
|
210
|
+
const headers = await this.getBaseHeaders();
|
|
211
|
+
const optionsResponse = await this.fetchWithOAuthRetry(this.config.url, { method: "OPTIONS", headers });
|
|
150
212
|
if (optionsResponse.ok)
|
|
151
213
|
return;
|
|
152
|
-
const
|
|
153
|
-
const headResponse = await fetch(this.config.url, { method: "HEAD", headers });
|
|
214
|
+
const headResponse = await this.fetchWithOAuthRetry(this.config.url, { method: "HEAD", headers });
|
|
154
215
|
if (!headResponse.ok) {
|
|
155
216
|
this.logger.warn(`[mcp-bridge] Streamable HTTP server probe: OPTIONS ${optionsResponse.status}, HEAD ${headResponse.status} (non-blocking, connection continues)`);
|
|
156
217
|
}
|
|
@@ -162,14 +223,18 @@ export class StreamableHttpTransport extends BaseTransport {
|
|
|
162
223
|
async disconnect() {
|
|
163
224
|
this.connected = false;
|
|
164
225
|
this.cleanupReconnectTimer();
|
|
226
|
+
for (const [, controller] of this.pendingRequestControllers) {
|
|
227
|
+
controller.abort();
|
|
228
|
+
}
|
|
229
|
+
this.pendingRequestControllers.clear();
|
|
165
230
|
// Send DELETE request if we have a session to clean up
|
|
166
231
|
if (this.sessionId && this.config.url) {
|
|
167
232
|
try {
|
|
168
|
-
const
|
|
169
|
-
headers
|
|
170
|
-
await
|
|
233
|
+
const base = await this.getBaseHeaders();
|
|
234
|
+
const headers = { ...base, "mcp-session-id": this.sessionId };
|
|
235
|
+
await this.fetchWithOAuthRetry(this.config.url, {
|
|
171
236
|
method: "DELETE",
|
|
172
|
-
headers
|
|
237
|
+
headers,
|
|
173
238
|
});
|
|
174
239
|
this.sessionId = undefined;
|
|
175
240
|
this.logger.info("Streamable HTTP session cleaned up");
|
|
@@ -180,4 +245,7 @@ export class StreamableHttpTransport extends BaseTransport {
|
|
|
180
245
|
}
|
|
181
246
|
this.rejectAllPending("Connection closed");
|
|
182
247
|
}
|
|
248
|
+
async shutdown() {
|
|
249
|
+
await this.disconnect();
|
|
250
|
+
}
|
|
183
251
|
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -4,12 +4,33 @@ export interface Logger {
|
|
|
4
4
|
error: (...args: unknown[]) => void;
|
|
5
5
|
debug: (...args: unknown[]) => void;
|
|
6
6
|
}
|
|
7
|
+
export type HttpAuthConfig = {
|
|
8
|
+
type: "bearer";
|
|
9
|
+
token: string;
|
|
10
|
+
} | {
|
|
11
|
+
type: "header";
|
|
12
|
+
headers: Record<string, string>;
|
|
13
|
+
} | {
|
|
14
|
+
type: "oauth2";
|
|
15
|
+
clientId: string;
|
|
16
|
+
clientSecret: string;
|
|
17
|
+
tokenUrl: string;
|
|
18
|
+
scopes?: string[];
|
|
19
|
+
audience?: string;
|
|
20
|
+
};
|
|
21
|
+
export interface RetryConfig {
|
|
22
|
+
maxAttempts?: number;
|
|
23
|
+
delayMs?: number;
|
|
24
|
+
backoffMultiplier?: number;
|
|
25
|
+
retryOn?: Array<"timeout" | "connection_error">;
|
|
26
|
+
}
|
|
7
27
|
export interface McpServerConfig {
|
|
8
28
|
transport: "sse" | "stdio" | "streamable-http";
|
|
9
29
|
/** Human-readable description for router tool description generation */
|
|
10
30
|
description?: string;
|
|
11
31
|
url?: string;
|
|
12
32
|
headers?: Record<string, string>;
|
|
33
|
+
auth?: HttpAuthConfig;
|
|
13
34
|
command?: string;
|
|
14
35
|
args?: string[];
|
|
15
36
|
env?: Record<string, string>;
|
|
@@ -20,6 +41,7 @@ export interface McpServerConfig {
|
|
|
20
41
|
allow?: string[];
|
|
21
42
|
};
|
|
22
43
|
maxResultChars?: number;
|
|
44
|
+
retry?: RetryConfig;
|
|
23
45
|
}
|
|
24
46
|
export interface McpClientConfig {
|
|
25
47
|
servers: Record<string, McpServerConfig>;
|
|
@@ -28,6 +50,7 @@ export interface McpClientConfig {
|
|
|
28
50
|
reconnectIntervalMs?: number;
|
|
29
51
|
connectionTimeoutMs?: number;
|
|
30
52
|
requestTimeoutMs?: number;
|
|
53
|
+
shutdownTimeoutMs?: number;
|
|
31
54
|
routerIdleTimeoutMs?: number;
|
|
32
55
|
routerMaxConcurrent?: number;
|
|
33
56
|
maxBatchSize?: number;
|
|
@@ -49,6 +72,7 @@ export interface McpClientConfig {
|
|
|
49
72
|
minCalls?: number;
|
|
50
73
|
decayMs?: number;
|
|
51
74
|
};
|
|
75
|
+
retry?: RetryConfig;
|
|
52
76
|
resultCache?: {
|
|
53
77
|
enabled?: boolean;
|
|
54
78
|
maxEntries?: number;
|
|
@@ -98,6 +122,7 @@ export interface McpResponse {
|
|
|
98
122
|
export interface McpTransport {
|
|
99
123
|
connect(): Promise<void>;
|
|
100
124
|
disconnect(): Promise<void>;
|
|
125
|
+
shutdown?(timeoutMs?: number): Promise<void>;
|
|
101
126
|
sendRequest(request: McpRequest): Promise<McpResponse>;
|
|
102
127
|
sendNotification(notification: any): Promise<void>;
|
|
103
128
|
isConnected(): boolean;
|
|
@@ -112,9 +137,6 @@ export interface McpServerConnection {
|
|
|
112
137
|
/** Bridge-level config loaded from ~/.mcp-bridge/config.json */
|
|
113
138
|
export interface BridgeConfig extends McpClientConfig {
|
|
114
139
|
http?: {
|
|
115
|
-
auth?:
|
|
116
|
-
type: "bearer";
|
|
117
|
-
token: string;
|
|
118
|
-
};
|
|
140
|
+
auth?: HttpAuthConfig;
|
|
119
141
|
};
|
|
120
142
|
}
|