@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
|
@@ -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,92 @@ 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
|
+
async doAuthCodeRefresh(serverName, stored, config) {
|
|
79
|
+
if (stored.refreshToken) {
|
|
80
|
+
try {
|
|
81
|
+
const refreshed = await this.refreshAuthCodeToken(stored, config);
|
|
82
|
+
this.tokenStore.save(serverName, refreshed);
|
|
83
|
+
return refreshed.accessToken;
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
this.logger.warn("[mcp-bridge] Auth code token refresh failed:", err);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Refresh failed or no refresh token
|
|
90
|
+
this.tokenStore.remove(serverName);
|
|
91
|
+
const error = new Error(`Authentication expired for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
|
|
92
|
+
error.code = -32006;
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
async refreshAuthCodeToken(stored, config) {
|
|
96
|
+
const formData = new URLSearchParams();
|
|
97
|
+
formData.set("grant_type", "refresh_token");
|
|
98
|
+
formData.set("refresh_token", stored.refreshToken);
|
|
99
|
+
if (config.clientId)
|
|
100
|
+
formData.set("client_id", config.clientId);
|
|
101
|
+
if (config.clientSecret)
|
|
102
|
+
formData.set("client_secret", config.clientSecret);
|
|
103
|
+
if (config.scopes?.length)
|
|
104
|
+
formData.set("scope", config.scopes.join(" "));
|
|
105
|
+
const response = await fetch(stored.tokenUrl, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
108
|
+
body: formData.toString(),
|
|
109
|
+
});
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
throw new Error(`OAuth2 refresh token exchange failed: HTTP ${response.status}`);
|
|
112
|
+
}
|
|
113
|
+
const payload = (await response.json());
|
|
114
|
+
if (!payload.access_token) {
|
|
115
|
+
throw new Error("OAuth2 refresh response missing access_token");
|
|
116
|
+
}
|
|
117
|
+
const expiresIn = Number.isFinite(payload.expires_in)
|
|
118
|
+
? Number(payload.expires_in)
|
|
119
|
+
: DEFAULT_EXPIRES_IN_SECONDS;
|
|
120
|
+
const expiresAt = Date.now() + Math.max(0, expiresIn - EXPIRY_BUFFER_SECONDS) * 1000;
|
|
121
|
+
return {
|
|
122
|
+
accessToken: payload.access_token,
|
|
123
|
+
refreshToken: payload.refresh_token ?? stored.refreshToken,
|
|
124
|
+
expiresAt,
|
|
125
|
+
tokenUrl: stored.tokenUrl,
|
|
126
|
+
clientId: config.clientId,
|
|
127
|
+
scopes: config.scopes,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
41
130
|
makeKey(tokenUrl, clientId) {
|
|
42
131
|
return `${tokenUrl}::${clientId}`;
|
|
43
132
|
}
|
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, 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,19 @@ 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;
|
|
81
86
|
export declare function resolveOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): OAuth2Config;
|
|
82
|
-
export declare function
|
|
87
|
+
export declare function resolveAuthCodeOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): AuthCodeOAuth2Config;
|
|
88
|
+
export declare function resolveAuthHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>, serverName?: string): Promise<Record<string, string>>;
|
|
83
89
|
/**
|
|
84
90
|
* Resolve server headers and merge auth headers (auth takes precedence).
|
|
85
91
|
*/
|
|
86
92
|
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
|
|
93
|
+
export declare function resolveServerHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>, serverName?: string): Promise<Record<string, string>>;
|
|
88
94
|
/**
|
|
89
95
|
* Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
|
|
90
96
|
*/
|
|
@@ -181,25 +181,54 @@ export function resolveAuthHeaders(config, extraEnv, envFallback) {
|
|
|
181
181
|
}
|
|
182
182
|
throw new Error("[mcp-bridge] OAuth2 auth requires async header resolution via resolveAuthHeadersAsync");
|
|
183
183
|
}
|
|
184
|
+
/** Check whether an oauth2 auth config uses the authorization_code grant type. */
|
|
185
|
+
export function isAuthCodeOAuth2(auth) {
|
|
186
|
+
return auth.grantType === "authorization_code";
|
|
187
|
+
}
|
|
184
188
|
export function resolveOAuth2Config(config, extraEnv, envFallback) {
|
|
185
189
|
if (!config.auth || config.auth.type !== "oauth2") {
|
|
186
190
|
throw new Error("[mcp-bridge] resolveOAuth2Config called for non-oauth2 auth config");
|
|
187
191
|
}
|
|
192
|
+
if (isAuthCodeOAuth2(config.auth)) {
|
|
193
|
+
throw new Error("[mcp-bridge] resolveOAuth2Config called for authorization_code config — use resolveAuthCodeOAuth2Config instead");
|
|
194
|
+
}
|
|
188
195
|
const scopes = config.auth.scopes?.map((scope, index) => resolveEnvVars(scope, `oauth2 scope[${index}]`, extraEnv, envFallback));
|
|
189
196
|
return {
|
|
190
197
|
clientId: resolveEnvVars(config.auth.clientId, "oauth2 clientId", extraEnv, envFallback),
|
|
191
198
|
clientSecret: resolveEnvVars(config.auth.clientSecret, "oauth2 clientSecret", extraEnv, envFallback),
|
|
192
199
|
tokenUrl: resolveEnvVars(config.auth.tokenUrl, "oauth2 tokenUrl", extraEnv, envFallback),
|
|
193
200
|
...(scopes && scopes.length > 0 ? { scopes } : {}),
|
|
194
|
-
...(config.auth.audience
|
|
201
|
+
...("audience" in config.auth && config.auth.audience
|
|
195
202
|
? { audience: resolveEnvVars(config.auth.audience, "oauth2 audience", extraEnv, envFallback) }
|
|
196
203
|
: {}),
|
|
197
204
|
};
|
|
198
205
|
}
|
|
199
|
-
export
|
|
206
|
+
export function resolveAuthCodeOAuth2Config(config, extraEnv, envFallback) {
|
|
207
|
+
if (!config.auth || config.auth.type !== "oauth2" || !isAuthCodeOAuth2(config.auth)) {
|
|
208
|
+
throw new Error("[mcp-bridge] resolveAuthCodeOAuth2Config called for non-authorization_code auth config");
|
|
209
|
+
}
|
|
210
|
+
const auth = config.auth;
|
|
211
|
+
const scopes = auth.scopes?.map((scope, index) => resolveEnvVars(scope, `oauth2 scope[${index}]`, extraEnv, envFallback));
|
|
212
|
+
return {
|
|
213
|
+
grantType: "authorization_code",
|
|
214
|
+
tokenUrl: resolveEnvVars(auth.tokenUrl, "oauth2 tokenUrl", extraEnv, envFallback),
|
|
215
|
+
...(auth.clientId ? { clientId: resolveEnvVars(auth.clientId, "oauth2 clientId", extraEnv, envFallback) } : {}),
|
|
216
|
+
...(auth.clientSecret ? { clientSecret: resolveEnvVars(auth.clientSecret, "oauth2 clientSecret", extraEnv, envFallback) } : {}),
|
|
217
|
+
...(scopes && scopes.length > 0 ? { scopes } : {}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
export async function resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback, serverName) {
|
|
200
221
|
if (!config.auth)
|
|
201
222
|
return {};
|
|
202
223
|
if (config.auth.type === "oauth2") {
|
|
224
|
+
if (isAuthCodeOAuth2(config.auth)) {
|
|
225
|
+
if (!serverName) {
|
|
226
|
+
throw new Error("[mcp-bridge] serverName is required for authorization_code OAuth2 flow");
|
|
227
|
+
}
|
|
228
|
+
const authCodeConfig = resolveAuthCodeOAuth2Config(config, extraEnv, envFallback);
|
|
229
|
+
const token = await tokenManager.getTokenForAuthCode(serverName, authCodeConfig);
|
|
230
|
+
return { Authorization: `Bearer ${token}` };
|
|
231
|
+
}
|
|
203
232
|
const oauth2Config = resolveOAuth2Config(config, extraEnv, envFallback);
|
|
204
233
|
const token = await tokenManager.getToken(oauth2Config);
|
|
205
234
|
return { Authorization: `Bearer ${token}` };
|
|
@@ -214,9 +243,9 @@ export function resolveServerHeaders(config, extraEnv, envFallback) {
|
|
|
214
243
|
const auth = resolveAuthHeaders(config, extraEnv, envFallback);
|
|
215
244
|
return { ...base, ...auth };
|
|
216
245
|
}
|
|
217
|
-
export async function resolveServerHeadersAsync(config, tokenManager, extraEnv, envFallback) {
|
|
246
|
+
export async function resolveServerHeadersAsync(config, tokenManager, extraEnv, envFallback, serverName) {
|
|
218
247
|
const base = resolveEnvRecord(config.headers || {}, "header", extraEnv, envFallback);
|
|
219
|
-
const auth = await resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback);
|
|
248
|
+
const auth = await resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback, serverName);
|
|
220
249
|
return { ...base, ...auth };
|
|
221
250
|
}
|
|
222
251
|
/**
|
|
@@ -7,8 +7,9 @@ export declare class SseTransport extends BaseTransport {
|
|
|
7
7
|
private resolvedHeaders;
|
|
8
8
|
private pendingRequestControllers;
|
|
9
9
|
private readonly tokenManager;
|
|
10
|
+
private readonly serverName?;
|
|
10
11
|
protected get transportName(): string;
|
|
11
|
-
constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: RequestIdGenerator);
|
|
12
|
+
constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: RequestIdGenerator, serverName?: string);
|
|
12
13
|
connect(): Promise<void>;
|
|
13
14
|
private _onEndpointReceived;
|
|
14
15
|
private getBaseHeaders;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
2
|
-
import { BaseTransport, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
|
|
2
|
+
import { BaseTransport, isAuthCodeOAuth2, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
|
|
3
3
|
export class SseTransport extends BaseTransport {
|
|
4
4
|
endpointUrl = null;
|
|
5
5
|
sseAbortController = null;
|
|
6
6
|
resolvedHeaders = null;
|
|
7
7
|
pendingRequestControllers = new Map();
|
|
8
8
|
tokenManager;
|
|
9
|
+
serverName;
|
|
9
10
|
get transportName() { return "SSE"; }
|
|
10
|
-
constructor(config, clientConfig, logger, onReconnected, tokenManager, requestIdGenerator) {
|
|
11
|
+
constructor(config, clientConfig, logger, onReconnected, tokenManager, requestIdGenerator, serverName) {
|
|
11
12
|
super(config, clientConfig, logger, onReconnected, requestIdGenerator);
|
|
12
13
|
this.tokenManager = tokenManager ?? new OAuth2TokenManager(logger);
|
|
14
|
+
this.serverName = serverName;
|
|
13
15
|
}
|
|
14
16
|
async connect() {
|
|
15
17
|
if (!this.config.url) {
|
|
@@ -52,7 +54,7 @@ export class SseTransport extends BaseTransport {
|
|
|
52
54
|
}
|
|
53
55
|
async refreshResolvedHeaders() {
|
|
54
56
|
if (this.config.auth?.type === "oauth2") {
|
|
55
|
-
this.resolvedHeaders = await resolveServerHeadersAsync(this.config, this.tokenManager, undefined, this.clientConfig.envFallback);
|
|
57
|
+
this.resolvedHeaders = await resolveServerHeadersAsync(this.config, this.tokenManager, undefined, this.clientConfig.envFallback, this.serverName);
|
|
56
58
|
}
|
|
57
59
|
else {
|
|
58
60
|
this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
|
|
@@ -63,6 +65,9 @@ export class SseTransport extends BaseTransport {
|
|
|
63
65
|
if (this.config.auth?.type !== "oauth2") {
|
|
64
66
|
return;
|
|
65
67
|
}
|
|
68
|
+
if (isAuthCodeOAuth2(this.config.auth)) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
66
71
|
const oauth2Config = resolveOAuth2Config(this.config, undefined, this.clientConfig.envFallback);
|
|
67
72
|
this.tokenManager.invalidate(oauth2Config.tokenUrl, oauth2Config.clientId);
|
|
68
73
|
}
|
|
@@ -63,7 +63,7 @@ export class StdioTransport extends BaseTransport {
|
|
|
63
63
|
this.process.stdout.on("data", (data) => {
|
|
64
64
|
this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, data]);
|
|
65
65
|
// Safety limit: prevent unbounded buffer growth from misbehaving servers
|
|
66
|
-
const MAX_BUFFER =
|
|
66
|
+
const MAX_BUFFER = 10 * 1024 * 1024; // 10MB
|
|
67
67
|
if (this.stdoutBuffer.length > MAX_BUFFER) {
|
|
68
68
|
this.logger.error(`[mcp-bridge] Stdio buffer exceeded ${MAX_BUFFER} bytes, killing process`);
|
|
69
69
|
this.process?.kill();
|
|
@@ -6,8 +6,9 @@ export declare class StreamableHttpTransport extends BaseTransport {
|
|
|
6
6
|
private resolvedHeaders;
|
|
7
7
|
private pendingRequestControllers;
|
|
8
8
|
private readonly tokenManager;
|
|
9
|
+
private readonly serverName?;
|
|
9
10
|
protected get transportName(): string;
|
|
10
|
-
constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: RequestIdGenerator);
|
|
11
|
+
constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: RequestIdGenerator, serverName?: string);
|
|
11
12
|
connect(): Promise<void>;
|
|
12
13
|
private getBaseHeaders;
|
|
13
14
|
private refreshResolvedHeaders;
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
2
|
-
import { BaseTransport, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
|
|
2
|
+
import { BaseTransport, isAuthCodeOAuth2, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
|
|
3
3
|
export class StreamableHttpTransport extends BaseTransport {
|
|
4
4
|
sessionId;
|
|
5
5
|
resolvedHeaders = null;
|
|
6
6
|
pendingRequestControllers = new Map();
|
|
7
7
|
tokenManager;
|
|
8
|
+
serverName;
|
|
8
9
|
get transportName() { return "streamable-http"; }
|
|
9
|
-
constructor(config, clientConfig, logger, onReconnected, tokenManager, requestIdGenerator) {
|
|
10
|
+
constructor(config, clientConfig, logger, onReconnected, tokenManager, requestIdGenerator, serverName) {
|
|
10
11
|
super(config, clientConfig, logger, onReconnected, requestIdGenerator);
|
|
11
12
|
this.tokenManager = tokenManager ?? new OAuth2TokenManager(logger);
|
|
13
|
+
this.serverName = serverName;
|
|
12
14
|
}
|
|
13
15
|
async connect() {
|
|
14
16
|
if (!this.config.url) {
|
|
@@ -35,7 +37,7 @@ export class StreamableHttpTransport extends BaseTransport {
|
|
|
35
37
|
}
|
|
36
38
|
async refreshResolvedHeaders() {
|
|
37
39
|
if (this.config.auth?.type === "oauth2") {
|
|
38
|
-
this.resolvedHeaders = await resolveServerHeadersAsync(this.config, this.tokenManager, undefined, this.clientConfig.envFallback);
|
|
40
|
+
this.resolvedHeaders = await resolveServerHeadersAsync(this.config, this.tokenManager, undefined, this.clientConfig.envFallback, this.serverName);
|
|
39
41
|
}
|
|
40
42
|
else {
|
|
41
43
|
this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
|
|
@@ -46,6 +48,10 @@ export class StreamableHttpTransport extends BaseTransport {
|
|
|
46
48
|
if (this.config.auth?.type !== "oauth2") {
|
|
47
49
|
return;
|
|
48
50
|
}
|
|
51
|
+
// authorization_code tokens are managed via TokenStore, not the in-memory cache
|
|
52
|
+
if (isAuthCodeOAuth2(this.config.auth)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
49
55
|
const oauth2Config = resolveOAuth2Config(this.config, undefined, this.clientConfig.envFallback);
|
|
50
56
|
this.tokenManager.invalidate(oauth2Config.tokenUrl, oauth2Config.clientId);
|
|
51
57
|
}
|
|
@@ -111,10 +117,17 @@ export class StreamableHttpTransport extends BaseTransport {
|
|
|
111
117
|
try {
|
|
112
118
|
const contentType = response.headers.get("content-type") || "";
|
|
113
119
|
if (contentType.includes("text/event-stream")) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
//
|
|
120
|
+
// Stream SSE response incrementally using ReadableStream
|
|
121
|
+
// (previous implementation used response.text() which blocked until
|
|
122
|
+
// the entire response was received, causing timeouts on long-running calls)
|
|
123
|
+
if (!response.body) {
|
|
124
|
+
throw new Error("SSE response has no body stream");
|
|
125
|
+
}
|
|
126
|
+
const reader = response.body.getReader();
|
|
127
|
+
const decoder = new TextDecoder();
|
|
128
|
+
let partial = "";
|
|
117
129
|
let dataBuffer = [];
|
|
130
|
+
let hasData = false;
|
|
118
131
|
const dispatch = () => {
|
|
119
132
|
if (dataBuffer.length === 0)
|
|
120
133
|
return;
|
|
@@ -127,19 +140,37 @@ export class StreamableHttpTransport extends BaseTransport {
|
|
|
127
140
|
// skip malformed events
|
|
128
141
|
}
|
|
129
142
|
};
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
143
|
+
try {
|
|
144
|
+
while (true) {
|
|
145
|
+
const { done, value } = await reader.read();
|
|
146
|
+
if (done)
|
|
147
|
+
break;
|
|
148
|
+
partial += decoder.decode(value, { stream: true });
|
|
149
|
+
const lines = partial.split("\n");
|
|
150
|
+
// Keep the last (potentially incomplete) line in partial
|
|
151
|
+
partial = lines.pop() || "";
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
const trimmed = line.trim();
|
|
154
|
+
if (trimmed.startsWith("data:")) {
|
|
155
|
+
dataBuffer.push(trimmed.substring(5).trimStart());
|
|
156
|
+
hasData = true;
|
|
157
|
+
}
|
|
158
|
+
else if (trimmed === "" && dataBuffer.length > 0) {
|
|
159
|
+
dispatch();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
136
162
|
}
|
|
137
|
-
|
|
138
|
-
|
|
163
|
+
// Process any remaining partial line
|
|
164
|
+
if (partial.trim().startsWith("data:")) {
|
|
165
|
+
dataBuffer.push(partial.trim().substring(5).trimStart());
|
|
166
|
+
hasData = true;
|
|
139
167
|
}
|
|
168
|
+
// Dispatch any trailing data (server may omit final empty line)
|
|
169
|
+
dispatch();
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
reader.releaseLock();
|
|
140
173
|
}
|
|
141
|
-
// Dispatch any trailing data (server may omit final empty line)
|
|
142
|
-
dispatch();
|
|
143
174
|
if (!hasData) {
|
|
144
175
|
throw new Error("No data lines in SSE response");
|
|
145
176
|
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -17,6 +17,15 @@ export type HttpAuthConfig = {
|
|
|
17
17
|
tokenUrl: string;
|
|
18
18
|
scopes?: string[];
|
|
19
19
|
audience?: string;
|
|
20
|
+
} | {
|
|
21
|
+
type: "oauth2";
|
|
22
|
+
grantType: "authorization_code";
|
|
23
|
+
authorizationUrl: string;
|
|
24
|
+
tokenUrl: string;
|
|
25
|
+
clientId?: string;
|
|
26
|
+
clientSecret?: string;
|
|
27
|
+
scopes?: string[];
|
|
28
|
+
callbackPort?: number;
|
|
20
29
|
};
|
|
21
30
|
export interface RetryConfig {
|
|
22
31
|
maxAttempts?: number;
|