@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.
@@ -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
- const results = await Promise.all(calls.map(async (call) => {
102
- const callServer = typeof call?.server === "string" ? call.server : "";
103
- const callTool = typeof call?.tool === "string" ? call.tool : "";
104
- const response = await this.dispatch(callServer, "call", callTool, call?.params);
105
- if ("error" in response) {
106
- return {
107
- server: callServer,
108
- tool: callTool,
109
- error: {
110
- error: response.error,
111
- message: response.message,
112
- ...(response.available ? { available: response.available } : {}),
113
- ...(typeof response.code === "number" ? { code: response.code } : {})
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
- return {
118
- server: callServer,
119
- tool: callTool,
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
- try {
548
- await state.initPromise;
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
- constructor(logger: Logger);
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
- constructor(logger) {
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
  }
@@ -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
  /**
@@ -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, limit),
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 resolveAuthHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Promise<Record<string, string>>;
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>): Promise<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
  */