@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/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,43 @@ 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
|
+
}
|
|
17
|
+
export interface DeviceCodeOAuth2Config {
|
|
18
|
+
grantType: "device_code";
|
|
19
|
+
tokenUrl: string;
|
|
20
|
+
clientId: string;
|
|
21
|
+
scopes?: string[];
|
|
22
|
+
}
|
|
9
23
|
export declare class OAuth2TokenManager {
|
|
10
24
|
private readonly logger;
|
|
11
25
|
private readonly tokenCache;
|
|
12
26
|
private readonly inflight;
|
|
13
|
-
|
|
27
|
+
private readonly authCodeInflight;
|
|
28
|
+
private readonly tokenStore?;
|
|
29
|
+
constructor(logger: Logger, tokenStore?: TokenStore);
|
|
14
30
|
getToken(config: OAuth2Config): Promise<string>;
|
|
15
31
|
invalidate(tokenUrl: string, clientId: string): void;
|
|
16
32
|
clear(): void;
|
|
33
|
+
/**
|
|
34
|
+
* Get a token for an authorization_code flow server.
|
|
35
|
+
* Checks TokenStore, refreshes if expired, throws if unavailable.
|
|
36
|
+
*/
|
|
37
|
+
getTokenForAuthCode(serverName: string, config: AuthCodeOAuth2Config): Promise<string>;
|
|
38
|
+
/**
|
|
39
|
+
* Get a token for a device_code flow server.
|
|
40
|
+
* Checks TokenStore, refreshes if expired, throws if unavailable.
|
|
41
|
+
*/
|
|
42
|
+
getTokenForDeviceCode(serverName: string, config: DeviceCodeOAuth2Config): Promise<string>;
|
|
43
|
+
private doDeviceCodeRefresh;
|
|
44
|
+
private refreshDeviceCodeToken;
|
|
45
|
+
private doAuthCodeRefresh;
|
|
46
|
+
private refreshAuthCodeToken;
|
|
17
47
|
private makeKey;
|
|
18
48
|
private fetchToken;
|
|
19
49
|
private exchangeToken;
|
|
@@ -4,8 +4,11 @@ export class OAuth2TokenManager {
|
|
|
4
4
|
logger;
|
|
5
5
|
tokenCache = new Map();
|
|
6
6
|
inflight = new Map();
|
|
7
|
-
|
|
7
|
+
authCodeInflight = new Map();
|
|
8
|
+
tokenStore;
|
|
9
|
+
constructor(logger, tokenStore) {
|
|
8
10
|
this.logger = logger;
|
|
11
|
+
this.tokenStore = tokenStore;
|
|
9
12
|
}
|
|
10
13
|
async getToken(config) {
|
|
11
14
|
const key = this.makeKey(config.tokenUrl, config.clientId);
|
|
@@ -38,6 +41,173 @@ export class OAuth2TokenManager {
|
|
|
38
41
|
this.tokenCache.clear();
|
|
39
42
|
this.inflight.clear();
|
|
40
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Get a token for an authorization_code flow server.
|
|
46
|
+
* Checks TokenStore, refreshes if expired, throws if unavailable.
|
|
47
|
+
*/
|
|
48
|
+
async getTokenForAuthCode(serverName, config) {
|
|
49
|
+
if (!this.tokenStore) {
|
|
50
|
+
throw new Error(`Authentication required for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
|
|
51
|
+
}
|
|
52
|
+
const stored = this.tokenStore.load(serverName);
|
|
53
|
+
if (!stored) {
|
|
54
|
+
const err = new Error(`Authentication required for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
|
|
55
|
+
err.code = -32007;
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
if (stored.expiresAt > now) {
|
|
60
|
+
return stored.accessToken;
|
|
61
|
+
}
|
|
62
|
+
// Token expired — try refresh with inflight dedup to avoid
|
|
63
|
+
// concurrent requests both trying to refresh the same token
|
|
64
|
+
// (the second refresh would fail because the first invalidated the refresh_token)
|
|
65
|
+
const existingInflight = this.authCodeInflight.get(serverName);
|
|
66
|
+
if (existingInflight) {
|
|
67
|
+
return existingInflight;
|
|
68
|
+
}
|
|
69
|
+
const refreshPromise = this.doAuthCodeRefresh(serverName, stored, config);
|
|
70
|
+
this.authCodeInflight.set(serverName, refreshPromise);
|
|
71
|
+
try {
|
|
72
|
+
return await refreshPromise;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
this.authCodeInflight.delete(serverName);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Get a token for a device_code flow server.
|
|
80
|
+
* Checks TokenStore, refreshes if expired, throws if unavailable.
|
|
81
|
+
*/
|
|
82
|
+
async getTokenForDeviceCode(serverName, config) {
|
|
83
|
+
if (!this.tokenStore) {
|
|
84
|
+
throw new Error(`Authentication required for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
|
|
85
|
+
}
|
|
86
|
+
const stored = this.tokenStore.load(serverName);
|
|
87
|
+
if (!stored) {
|
|
88
|
+
const err = new Error(`Authentication required for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
|
|
89
|
+
err.code = -32007;
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
if (stored.expiresAt > now) {
|
|
94
|
+
return stored.accessToken;
|
|
95
|
+
}
|
|
96
|
+
// Token expired — try refresh with inflight dedup
|
|
97
|
+
const existingInflight = this.authCodeInflight.get(serverName);
|
|
98
|
+
if (existingInflight) {
|
|
99
|
+
return existingInflight;
|
|
100
|
+
}
|
|
101
|
+
const refreshPromise = this.doDeviceCodeRefresh(serverName, stored, config);
|
|
102
|
+
this.authCodeInflight.set(serverName, refreshPromise);
|
|
103
|
+
try {
|
|
104
|
+
return await refreshPromise;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
this.authCodeInflight.delete(serverName);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async doDeviceCodeRefresh(serverName, stored, config) {
|
|
111
|
+
if (stored.refreshToken) {
|
|
112
|
+
try {
|
|
113
|
+
const refreshed = await this.refreshDeviceCodeToken(stored, config);
|
|
114
|
+
this.tokenStore.save(serverName, refreshed);
|
|
115
|
+
return refreshed.accessToken;
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
this.logger.warn("[mcp-bridge] Device code token refresh failed:", err);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Refresh failed or no refresh token
|
|
122
|
+
this.tokenStore.remove(serverName);
|
|
123
|
+
const error = new Error(`Authentication expired for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
|
|
124
|
+
error.code = -32006;
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
async refreshDeviceCodeToken(stored, config) {
|
|
128
|
+
const formData = new URLSearchParams();
|
|
129
|
+
formData.set("grant_type", "refresh_token");
|
|
130
|
+
formData.set("refresh_token", stored.refreshToken);
|
|
131
|
+
formData.set("client_id", config.clientId);
|
|
132
|
+
if (config.scopes?.length)
|
|
133
|
+
formData.set("scope", config.scopes.join(" "));
|
|
134
|
+
const response = await fetch(stored.tokenUrl, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
137
|
+
body: formData.toString(),
|
|
138
|
+
});
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new Error(`OAuth2 refresh token exchange failed: HTTP ${response.status}`);
|
|
141
|
+
}
|
|
142
|
+
const payload = (await response.json());
|
|
143
|
+
if (!payload.access_token) {
|
|
144
|
+
throw new Error("OAuth2 refresh response missing access_token");
|
|
145
|
+
}
|
|
146
|
+
const expiresIn = Number.isFinite(payload.expires_in)
|
|
147
|
+
? Number(payload.expires_in)
|
|
148
|
+
: DEFAULT_EXPIRES_IN_SECONDS;
|
|
149
|
+
const expiresAt = Date.now() + Math.max(0, expiresIn - EXPIRY_BUFFER_SECONDS) * 1000;
|
|
150
|
+
return {
|
|
151
|
+
accessToken: payload.access_token,
|
|
152
|
+
refreshToken: payload.refresh_token ?? stored.refreshToken,
|
|
153
|
+
expiresAt,
|
|
154
|
+
tokenUrl: stored.tokenUrl,
|
|
155
|
+
clientId: config.clientId,
|
|
156
|
+
scopes: config.scopes,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
async doAuthCodeRefresh(serverName, stored, config) {
|
|
160
|
+
if (stored.refreshToken) {
|
|
161
|
+
try {
|
|
162
|
+
const refreshed = await this.refreshAuthCodeToken(stored, config);
|
|
163
|
+
this.tokenStore.save(serverName, refreshed);
|
|
164
|
+
return refreshed.accessToken;
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
this.logger.warn("[mcp-bridge] Auth code token refresh failed:", err);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Refresh failed or no refresh token
|
|
171
|
+
this.tokenStore.remove(serverName);
|
|
172
|
+
const error = new Error(`Authentication expired for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
|
|
173
|
+
error.code = -32006;
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
async refreshAuthCodeToken(stored, config) {
|
|
177
|
+
const formData = new URLSearchParams();
|
|
178
|
+
formData.set("grant_type", "refresh_token");
|
|
179
|
+
formData.set("refresh_token", stored.refreshToken);
|
|
180
|
+
if (config.clientId)
|
|
181
|
+
formData.set("client_id", config.clientId);
|
|
182
|
+
if (config.clientSecret)
|
|
183
|
+
formData.set("client_secret", config.clientSecret);
|
|
184
|
+
if (config.scopes?.length)
|
|
185
|
+
formData.set("scope", config.scopes.join(" "));
|
|
186
|
+
const response = await fetch(stored.tokenUrl, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
189
|
+
body: formData.toString(),
|
|
190
|
+
});
|
|
191
|
+
if (!response.ok) {
|
|
192
|
+
throw new Error(`OAuth2 refresh token exchange failed: HTTP ${response.status}`);
|
|
193
|
+
}
|
|
194
|
+
const payload = (await response.json());
|
|
195
|
+
if (!payload.access_token) {
|
|
196
|
+
throw new Error("OAuth2 refresh response missing access_token");
|
|
197
|
+
}
|
|
198
|
+
const expiresIn = Number.isFinite(payload.expires_in)
|
|
199
|
+
? Number(payload.expires_in)
|
|
200
|
+
: DEFAULT_EXPIRES_IN_SECONDS;
|
|
201
|
+
const expiresAt = Date.now() + Math.max(0, expiresIn - EXPIRY_BUFFER_SECONDS) * 1000;
|
|
202
|
+
return {
|
|
203
|
+
accessToken: payload.access_token,
|
|
204
|
+
refreshToken: payload.refresh_token ?? stored.refreshToken,
|
|
205
|
+
expiresAt,
|
|
206
|
+
tokenUrl: stored.tokenUrl,
|
|
207
|
+
clientId: config.clientId,
|
|
208
|
+
scopes: config.scopes,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
41
211
|
makeKey(tokenUrl, clientId) {
|
|
42
212
|
return `${tokenUrl}::${clientId}`;
|
|
43
213
|
}
|
package/dist/src/security.d.ts
CHANGED
|
@@ -16,6 +16,10 @@ export declare function isToolAllowed(toolName: string, serverConfig: McpServerC
|
|
|
16
16
|
/**
|
|
17
17
|
* Apply max result size truncation.
|
|
18
18
|
* Returns the result as-is or a truncation wrapper.
|
|
19
|
+
*
|
|
20
|
+
* Uses JSON-aware truncation: tries to produce valid JSON by truncating
|
|
21
|
+
* at the object/array level rather than slicing raw JSON strings
|
|
22
|
+
* (which produces invalid JSON that LLMs may hallucinate around).
|
|
19
23
|
*/
|
|
20
24
|
export declare function applyMaxResultSize(result: any, serverConfig: McpServerConfig, clientConfig: McpClientConfig): any;
|
|
21
25
|
/**
|
package/dist/src/security.js
CHANGED
|
@@ -87,6 +87,10 @@ export function isToolAllowed(toolName, serverConfig) {
|
|
|
87
87
|
/**
|
|
88
88
|
* Apply max result size truncation.
|
|
89
89
|
* Returns the result as-is or a truncation wrapper.
|
|
90
|
+
*
|
|
91
|
+
* Uses JSON-aware truncation: tries to produce valid JSON by truncating
|
|
92
|
+
* at the object/array level rather than slicing raw JSON strings
|
|
93
|
+
* (which produces invalid JSON that LLMs may hallucinate around).
|
|
90
94
|
*/
|
|
91
95
|
export function applyMaxResultSize(result, serverConfig, clientConfig) {
|
|
92
96
|
const limit = serverConfig.maxResultChars ?? clientConfig.maxResultChars;
|
|
@@ -95,12 +99,95 @@ export function applyMaxResultSize(result, serverConfig, clientConfig) {
|
|
|
95
99
|
const serialized = JSON.stringify(result);
|
|
96
100
|
if (serialized.length <= limit)
|
|
97
101
|
return result;
|
|
102
|
+
// Try JSON-aware truncation: if the result is an array, take fewer elements;
|
|
103
|
+
// if it's an object with a nested array, truncate the largest array.
|
|
104
|
+
const truncated = truncateJsonAware(result, limit);
|
|
105
|
+
if (truncated !== null) {
|
|
106
|
+
return {
|
|
107
|
+
_truncated: true,
|
|
108
|
+
_originalLength: serialized.length,
|
|
109
|
+
result: truncated,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// Fallback: stringify and cut at a safe boundary, then wrap as a string
|
|
113
|
+
// to ensure the consumer always gets valid JSON
|
|
114
|
+
let cutPoint = Math.min(limit, serialized.length);
|
|
115
|
+
// Try to cut at the last complete JSON token boundary (comma, closing bracket, or newline)
|
|
116
|
+
const lastSafe = Math.max(serialized.lastIndexOf(",", cutPoint), serialized.lastIndexOf("}", cutPoint), serialized.lastIndexOf("]", cutPoint), serialized.lastIndexOf("\n", cutPoint));
|
|
117
|
+
if (lastSafe > cutPoint * 0.5) {
|
|
118
|
+
cutPoint = lastSafe + 1;
|
|
119
|
+
}
|
|
98
120
|
return {
|
|
99
121
|
_truncated: true,
|
|
100
122
|
_originalLength: serialized.length,
|
|
101
|
-
result: serialized.slice(0,
|
|
123
|
+
result: serialized.slice(0, cutPoint) + "…",
|
|
124
|
+
_note: "Result truncated. Original response exceeded size limit.",
|
|
102
125
|
};
|
|
103
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* JSON-aware truncation: reduce array sizes to fit within the char limit.
|
|
129
|
+
* Returns the truncated value or null if not applicable.
|
|
130
|
+
*/
|
|
131
|
+
function truncateJsonAware(value, limit) {
|
|
132
|
+
if (Array.isArray(value)) {
|
|
133
|
+
return truncateArray(value, limit);
|
|
134
|
+
}
|
|
135
|
+
if (value !== null && typeof value === "object") {
|
|
136
|
+
// Find the largest array field and truncate it
|
|
137
|
+
let largestKey = null;
|
|
138
|
+
let largestLen = 0;
|
|
139
|
+
for (const [k, v] of Object.entries(value)) {
|
|
140
|
+
if (Array.isArray(v) && v.length > largestLen) {
|
|
141
|
+
largestKey = k;
|
|
142
|
+
largestLen = v.length;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (largestKey && largestLen > 1) {
|
|
146
|
+
const copy = { ...value };
|
|
147
|
+
copy[largestKey] = truncateArray(value[largestKey], limit);
|
|
148
|
+
if (JSON.stringify(copy).length <= limit) {
|
|
149
|
+
return copy;
|
|
150
|
+
}
|
|
151
|
+
// Still too large — try with fewer elements
|
|
152
|
+
return truncateObjectWithArrays(value, limit);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
function truncateArray(arr, limit) {
|
|
158
|
+
// Binary search for the number of elements that fit
|
|
159
|
+
let lo = 0;
|
|
160
|
+
let hi = arr.length;
|
|
161
|
+
while (lo < hi) {
|
|
162
|
+
const mid = (lo + hi + 1) >>> 1;
|
|
163
|
+
const slice = arr.slice(0, mid);
|
|
164
|
+
if (JSON.stringify(slice).length <= limit) {
|
|
165
|
+
lo = mid;
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
hi = mid - 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return arr.slice(0, Math.max(1, lo));
|
|
172
|
+
}
|
|
173
|
+
function truncateObjectWithArrays(obj, limit) {
|
|
174
|
+
const copy = { ...obj };
|
|
175
|
+
// Progressively halve all arrays until it fits
|
|
176
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
177
|
+
let changed = false;
|
|
178
|
+
for (const [k, v] of Object.entries(copy)) {
|
|
179
|
+
if (Array.isArray(v) && v.length > 1) {
|
|
180
|
+
copy[k] = v.slice(0, Math.max(1, Math.ceil(v.length / 2)));
|
|
181
|
+
changed = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (JSON.stringify(copy).length <= limit)
|
|
185
|
+
return copy;
|
|
186
|
+
if (!changed)
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
104
191
|
/**
|
|
105
192
|
* Apply trust level wrapping/sanitization.
|
|
106
193
|
*/
|
|
@@ -6,6 +6,7 @@ import { SseTransport } from "./transport-sse.js";
|
|
|
6
6
|
import { StdioTransport } from "./transport-stdio.js";
|
|
7
7
|
import { StreamableHttpTransport } from "./transport-streamable-http.js";
|
|
8
8
|
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
9
|
+
import { FileTokenStore } from "./token-store.js";
|
|
9
10
|
/**
|
|
10
11
|
* Standalone MCP server that wraps the router.
|
|
11
12
|
* Implements the MCP protocol (initialize, tools/list, tools/call)
|
|
@@ -25,7 +26,7 @@ export class StandaloneServer {
|
|
|
25
26
|
constructor(config, logger) {
|
|
26
27
|
this.config = config;
|
|
27
28
|
this.logger = logger;
|
|
28
|
-
this.tokenManager = new OAuth2TokenManager(logger);
|
|
29
|
+
this.tokenManager = new OAuth2TokenManager(logger, new FileTokenStore());
|
|
29
30
|
if (this.isRouterMode()) {
|
|
30
31
|
this.router = new McpRouter(config.servers || {}, config, logger);
|
|
31
32
|
}
|
|
@@ -408,11 +409,11 @@ export class StandaloneServer {
|
|
|
408
409
|
};
|
|
409
410
|
switch (serverConfig.transport) {
|
|
410
411
|
case "sse":
|
|
411
|
-
return new SseTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId());
|
|
412
|
+
return new SseTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId(), serverName);
|
|
412
413
|
case "stdio":
|
|
413
414
|
return new StdioTransport(serverConfig, this.config, this.logger, onReconnected, () => this.nextRequestId());
|
|
414
415
|
case "streamable-http":
|
|
415
|
-
return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId());
|
|
416
|
+
return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId(), serverName);
|
|
416
417
|
default:
|
|
417
418
|
throw new Error(`Unsupported transport: ${serverConfig.transport}`);
|
|
418
419
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface StoredToken {
|
|
2
|
+
accessToken: string;
|
|
3
|
+
refreshToken?: string;
|
|
4
|
+
expiresAt: number;
|
|
5
|
+
tokenUrl: string;
|
|
6
|
+
clientId?: string;
|
|
7
|
+
scopes?: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface TokenStore {
|
|
10
|
+
load(serverName: string): StoredToken | null;
|
|
11
|
+
save(serverName: string, token: StoredToken): void;
|
|
12
|
+
remove(serverName: string): void;
|
|
13
|
+
list(): {
|
|
14
|
+
serverName: string;
|
|
15
|
+
token: StoredToken;
|
|
16
|
+
}[];
|
|
17
|
+
}
|
|
18
|
+
export declare class FileTokenStore implements TokenStore {
|
|
19
|
+
private readonly tokensDir;
|
|
20
|
+
constructor(tokensDir?: string);
|
|
21
|
+
load(serverName: string): StoredToken | null;
|
|
22
|
+
save(serverName: string, token: StoredToken): void;
|
|
23
|
+
remove(serverName: string): void;
|
|
24
|
+
list(): {
|
|
25
|
+
serverName: string;
|
|
26
|
+
token: StoredToken;
|
|
27
|
+
}[];
|
|
28
|
+
private tokenPath;
|
|
29
|
+
private ensureDir;
|
|
30
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, unlinkSync, readdirSync, existsSync, mkdirSync, chmodSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
const DEFAULT_TOKENS_DIR = join(homedir(), ".mcp-bridge", "tokens");
|
|
5
|
+
export class FileTokenStore {
|
|
6
|
+
tokensDir;
|
|
7
|
+
constructor(tokensDir) {
|
|
8
|
+
this.tokensDir = tokensDir ?? DEFAULT_TOKENS_DIR;
|
|
9
|
+
}
|
|
10
|
+
load(serverName) {
|
|
11
|
+
const filePath = this.tokenPath(serverName);
|
|
12
|
+
if (!existsSync(filePath))
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
16
|
+
if (!raw.accessToken || !raw.tokenUrl || typeof raw.expiresAt !== "number") {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return raw;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
save(serverName, token) {
|
|
26
|
+
this.ensureDir();
|
|
27
|
+
const filePath = this.tokenPath(serverName);
|
|
28
|
+
writeFileSync(filePath, JSON.stringify(token, null, 2) + "\n", "utf-8");
|
|
29
|
+
try {
|
|
30
|
+
chmodSync(filePath, 0o600);
|
|
31
|
+
}
|
|
32
|
+
catch { /* Windows doesn't support chmod */ }
|
|
33
|
+
}
|
|
34
|
+
remove(serverName) {
|
|
35
|
+
const filePath = this.tokenPath(serverName);
|
|
36
|
+
if (existsSync(filePath)) {
|
|
37
|
+
unlinkSync(filePath);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
list() {
|
|
41
|
+
if (!existsSync(this.tokensDir))
|
|
42
|
+
return [];
|
|
43
|
+
const results = [];
|
|
44
|
+
for (const file of readdirSync(this.tokensDir)) {
|
|
45
|
+
if (!file.endsWith(".json"))
|
|
46
|
+
continue;
|
|
47
|
+
const serverName = file.slice(0, -5);
|
|
48
|
+
const token = this.load(serverName);
|
|
49
|
+
if (token) {
|
|
50
|
+
results.push({ serverName, token });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return results;
|
|
54
|
+
}
|
|
55
|
+
tokenPath(serverName) {
|
|
56
|
+
// Sanitize server name to prevent path traversal
|
|
57
|
+
const safe = serverName.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
58
|
+
return join(this.tokensDir, `${safe}.json`);
|
|
59
|
+
}
|
|
60
|
+
ensureDir() {
|
|
61
|
+
if (!existsSync(this.tokensDir)) {
|
|
62
|
+
mkdirSync(this.tokensDir, { recursive: true });
|
|
63
|
+
try {
|
|
64
|
+
chmodSync(this.tokensDir, 0o700);
|
|
65
|
+
}
|
|
66
|
+
catch { /* Windows */ }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { McpTransport, McpRequest, McpResponse, McpServerConfig, McpClientConfig, Logger, JsonRpcMessage, RequestIdGenerator } from "./types.js";
|
|
2
|
-
import type { OAuth2Config, OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
2
|
+
import type { OAuth2Config, AuthCodeOAuth2Config, DeviceCodeOAuth2Config, OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
3
3
|
export type PendingRequest = {
|
|
4
4
|
resolve: (value: McpResponse) => void;
|
|
5
5
|
reject: (reason: Error) => void;
|
|
@@ -78,13 +78,25 @@ export declare function resolveArgs(args: string[], extraEnv?: Record<string, st
|
|
|
78
78
|
* Resolve auth config into HTTP headers.
|
|
79
79
|
*/
|
|
80
80
|
export declare function resolveAuthHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
|
|
81
|
+
/** Check whether an oauth2 auth config uses the authorization_code grant type. */
|
|
82
|
+
export declare function isAuthCodeOAuth2(auth: {
|
|
83
|
+
type: "oauth2";
|
|
84
|
+
grantType?: string;
|
|
85
|
+
}): boolean;
|
|
86
|
+
/** Check whether an oauth2 auth config uses the device_code grant type. */
|
|
87
|
+
export declare function isDeviceCodeOAuth2(auth: {
|
|
88
|
+
type: "oauth2";
|
|
89
|
+
grantType?: string;
|
|
90
|
+
}): boolean;
|
|
81
91
|
export declare function resolveOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): OAuth2Config;
|
|
82
|
-
export declare function
|
|
92
|
+
export declare function resolveAuthCodeOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): AuthCodeOAuth2Config;
|
|
93
|
+
export declare function resolveDeviceCodeOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): DeviceCodeOAuth2Config;
|
|
94
|
+
export declare function resolveAuthHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>, serverName?: string): Promise<Record<string, string>>;
|
|
83
95
|
/**
|
|
84
96
|
* Resolve server headers and merge auth headers (auth takes precedence).
|
|
85
97
|
*/
|
|
86
98
|
export declare function resolveServerHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
|
|
87
|
-
export declare function resolveServerHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string
|
|
99
|
+
export declare function resolveServerHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>, serverName?: string): Promise<Record<string, string>>;
|
|
88
100
|
/**
|
|
89
101
|
* Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
|
|
90
102
|
*/
|