@aiwerk/mcp-bridge 2.1.4 → 2.5.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.
Files changed (40) hide show
  1. package/README.md +39 -2
  2. package/dist/src/index.d.ts +1 -1
  3. package/dist/src/mcp-router.d.ts +6 -3
  4. package/dist/src/mcp-router.js +37 -10
  5. package/dist/src/security.js +17 -16
  6. package/dist/src/standalone-server.d.ts +2 -0
  7. package/dist/src/standalone-server.js +20 -15
  8. package/dist/src/transport-base.d.ts +5 -2
  9. package/dist/src/transport-base.js +9 -2
  10. package/dist/src/transport-sse.d.ts +2 -2
  11. package/dist/src/transport-sse.js +3 -4
  12. package/dist/src/transport-stdio.js +10 -4
  13. package/dist/src/transport-streamable-http.d.ts +2 -2
  14. package/dist/src/transport-streamable-http.js +3 -4
  15. package/dist/src/types.d.ts +6 -1
  16. package/dist/src/types.js +5 -6
  17. package/package.json +2 -2
  18. package/scripts/validate-recipes.sh +13 -0
  19. package/servers/apify/recipe.json +20 -7
  20. package/servers/atlassian/recipe.json +20 -8
  21. package/servers/chrome-devtools/recipe.json +22 -8
  22. package/servers/github/recipe.json +20 -8
  23. package/servers/google-maps/recipe.json +25 -10
  24. package/servers/hetzner/recipe.json +23 -9
  25. package/servers/hostinger/recipe.json +24 -9
  26. package/servers/imap-email/README.md +37 -0
  27. package/servers/imap-email/recipe.json +47 -6
  28. package/servers/index.json +292 -71
  29. package/servers/linear/recipe.json +23 -9
  30. package/servers/miro/recipe.json +26 -9
  31. package/servers/notion/recipe.json +24 -9
  32. package/servers/stripe/recipe.json +26 -9
  33. package/servers/tavily/recipe.json +24 -9
  34. package/servers/todoist/recipe.json +24 -9
  35. package/servers/wise/recipe.json +23 -9
  36. package/servers/atlassian/config.json +0 -19
  37. package/servers/chrome-devtools/config.json +0 -16
  38. package/servers/github/config.json +0 -21
  39. package/servers/hostinger/config.json +0 -17
  40. package/servers/wise/config.json +0 -16
package/README.md CHANGED
@@ -17,7 +17,7 @@ Most AI agents connect to MCP servers one-by-one. With 10+ servers, that's 10+ c
17
17
  - **Intent routing**: say what you need in plain language, the bridge finds the right tool
18
18
  - **Schema compression**: tool descriptions compressed ~57%, full schema on demand
19
19
  - **Security layer**: trust levels, tool deny/allow lists, result size limits
20
- - **HTTP auth**: bearer token, custom headers, and **OAuth2 Client Credentials** with automatic token management
20
+ - **HTTP auth**: bearer token, custom headers, **OAuth2 Client Credentials**, and **OAuth2 Authorization Code + PKCE** (interactive browser login)
21
21
  - **Result caching**: LRU cache with per-tool TTL overrides
22
22
  - **Batch calls**: parallel multi-tool execution via `action=batch`
23
23
  - **Multi-server resolution**: automatic tool disambiguation when multiple servers provide the same tool
@@ -67,7 +67,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
67
67
 
68
68
  ## Recipe Spec v2
69
69
 
70
- Bundled servers now ship with `recipe.json` using **Universal Recipe Spec v2.0**.
70
+ Bundled servers now ship with `recipe.json` using **Universal Recipe Spec v2.1** (15 servers with rich metadata: category, subcategory, origin, countries, audience, sideEffects).
71
71
  During install, MCP Bridge prefers `recipe.json` when present and falls back to legacy `config.json` (v1) for backwards compatibility.
72
72
 
73
73
  - Spec: [`docs/universal-recipe-spec.md`](docs/universal-recipe-spec.md)
@@ -398,6 +398,37 @@ SSE and streamable-HTTP transports support three auth methods:
398
398
 
399
399
  OAuth2 features: automatic token acquisition, caching with expiry-aware refresh, single-attempt 401 retry, env var substitution in credentials.
400
400
 
401
+ **OAuth2 Authorization Code + PKCE** (interactive browser login):
402
+
403
+ For MCP servers behind enterprise SSO or user-level OAuth2 that require browser-based login:
404
+
405
+ ```json
406
+ {
407
+ "auth": {
408
+ "type": "oauth2",
409
+ "grantType": "authorization_code",
410
+ "authorizationUrl": "https://auth.example.com/authorize",
411
+ "tokenUrl": "https://auth.example.com/oauth/token",
412
+ "clientId": "optional-public-client-id",
413
+ "scopes": ["read", "write"]
414
+ }
415
+ }
416
+ ```
417
+
418
+ Then authenticate via CLI:
419
+
420
+ ```bash
421
+ mcp-bridge auth login my-server # Opens browser, completes OAuth2 flow
422
+ mcp-bridge auth status # Check token status for all servers
423
+ mcp-bridge auth logout my-server # Remove stored token
424
+ ```
425
+
426
+ Features:
427
+ - **PKCE (RFC 7636)** — mandatory S256 code challenge, no `clientSecret` needed for public clients
428
+ - **Persistent tokens** — stored in `~/.mcp-bridge/tokens/` (chmod 600), survive bridge restarts
429
+ - **Automatic refresh** — tokens refreshed transparently via `refresh_token` grant
430
+ - **Actionable errors** — expired tokens return error with exact CLI command to re-authenticate
431
+
401
432
  ### Environment variables
402
433
 
403
434
  Secrets go in `~/.mcp-bridge/.env` (chmod 600 on init):
@@ -427,6 +458,10 @@ mcp-bridge servers # List configured servers
427
458
  mcp-bridge search <query> # Search catalog by keyword
428
459
  mcp-bridge update [--check] # Check for / install updates
429
460
  mcp-bridge --version # Print version
461
+
462
+ mcp-bridge auth login <server> # OAuth2 browser login (Authorization Code + PKCE)
463
+ mcp-bridge auth logout <server> # Remove stored token
464
+ mcp-bridge auth status # Show auth status for all servers
430
465
  ```
431
466
 
432
467
  ## Server Catalog
@@ -517,6 +552,8 @@ For production deployments with high security requirements, consider adding an e
517
552
  | ✅ | HTTP auth (bearer, headers) | 2.0.0 |
518
553
  | ✅ | Configurable retries + graceful shutdown | 2.0.0 |
519
554
  | ✅ | OAuth2 Client Credentials | 2.1.0 |
555
+ | ✅ | OAuth2 Authorization Code + PKCE | 2.5.0 |
556
+ | 🔜 | OAuth2 Device Code flow (headless) | planned |
520
557
  | 🔜 | Hosted bridge (bridge.aiwerk.ch) | planned |
521
558
  | 🔜 | Remote catalog integration | planned |
522
559
  | 🔜 | OpenTelemetry / Prometheus metrics | planned |
@@ -13,7 +13,7 @@ export type { ToolResolutionResult, ToolResolutionCandidate } from "./tool-resol
13
13
  export { convertJsonSchemaToTypeBox, createToolParameters, setTypeBoxLoader, setSchemaLogger } from "./schema-convert.js";
14
14
  export { initializeProtocol, fetchToolsList, PACKAGE_VERSION } from "./protocol.js";
15
15
  export { loadConfig, parseEnvFile, initConfigDir, getConfigDir } from "./config.js";
16
- export type { Logger, McpServerConfig, McpClientConfig, HttpAuthConfig, RetryConfig, McpTool, McpRequest, McpCallRequest, McpResponse, JsonRpcMessage, McpTransport, McpServerConnection, BridgeConfig, } from "./types.js";
16
+ export type { Logger, McpServerConfig, McpClientConfig, HttpAuthConfig, RetryConfig, McpTool, McpRequest, McpCallRequest, McpResponse, JsonRpcMessage, McpTransport, McpServerConnection, BridgeConfig, RequestIdState, RequestIdGenerator, } from "./types.js";
17
17
  export { nextRequestId } from "./types.js";
18
18
  export { pickRegisteredToolName } from "./tool-naming.js";
19
19
  export { StandaloneServer } from "./standalone-server.js";
@@ -93,9 +93,9 @@ export type RouterDispatchResponse = {
93
93
  code?: number;
94
94
  };
95
95
  export interface RouterTransportRefs {
96
- sse: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager) => McpTransport;
97
- stdio: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>) => McpTransport;
98
- streamableHttp: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager) => McpTransport;
96
+ sse: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: () => number) => McpTransport;
97
+ stdio: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, requestIdGenerator?: () => number) => McpTransport;
98
+ streamableHttp: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: () => number) => McpTransport;
99
99
  }
100
100
  export declare class McpRouter {
101
101
  private readonly servers;
@@ -103,12 +103,14 @@ export declare class McpRouter {
103
103
  private readonly logger;
104
104
  private readonly transportRefs;
105
105
  private readonly idleTimeoutMs;
106
+ private readonly connectErrorCooldownMs;
106
107
  private readonly maxConcurrent;
107
108
  private readonly resultCache;
108
109
  private readonly maxBatchSize;
109
110
  private readonly states;
110
111
  private readonly toolResolver;
111
112
  private readonly tokenManager;
113
+ private readonly requestIdState;
112
114
  private intentRouter;
113
115
  private promotion;
114
116
  constructor(servers: Record<string, McpServerConfig>, clientConfig: McpClientConfig, logger: Logger, transportRefs?: Partial<RouterTransportRefs>);
@@ -134,6 +136,7 @@ export declare class McpRouter {
134
136
  private enforceMaxConcurrent;
135
137
  private disconnectServer;
136
138
  private markUsed;
139
+ private nextRequestId;
137
140
  private createTransport;
138
141
  private extractRequiredParams;
139
142
  private error;
@@ -1,3 +1,4 @@
1
+ import { nextRequestId, } from "./types.js";
1
2
  import { SseTransport } from "./transport-sse.js";
2
3
  import { StdioTransport } from "./transport-stdio.js";
3
4
  import { StreamableHttpTransport } from "./transport-streamable-http.js";
@@ -11,6 +12,7 @@ import { ResultCache, createResultCacheKey } from "./result-cache.js";
11
12
  import { ToolResolver } from "./tool-resolution.js";
12
13
  import { OAuth2TokenManager } from "./oauth2-token-manager.js";
13
14
  const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
15
+ const DEFAULT_CONNECT_ERROR_COOLDOWN_MS = 10 * 1000;
14
16
  const DEFAULT_MAX_CONCURRENT = 5;
15
17
  const DEFAULT_MAX_BATCH_SIZE = 10;
16
18
  const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000;
@@ -20,12 +22,14 @@ export class McpRouter {
20
22
  logger;
21
23
  transportRefs;
22
24
  idleTimeoutMs;
25
+ connectErrorCooldownMs;
23
26
  maxConcurrent;
24
27
  resultCache;
25
28
  maxBatchSize;
26
29
  states = new Map();
27
30
  toolResolver;
28
31
  tokenManager;
32
+ requestIdState = { value: 0 };
29
33
  intentRouter = null;
30
34
  promotion = null;
31
35
  constructor(servers, clientConfig, logger, transportRefs) {
@@ -38,6 +42,7 @@ export class McpRouter {
38
42
  streamableHttp: transportRefs?.streamableHttp ?? StreamableHttpTransport
39
43
  };
40
44
  this.idleTimeoutMs = clientConfig.routerIdleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
45
+ this.connectErrorCooldownMs = clientConfig.routerConnectErrorCooldownMs ?? DEFAULT_CONNECT_ERROR_COOLDOWN_MS;
41
46
  this.maxConcurrent = clientConfig.routerMaxConcurrent ?? DEFAULT_MAX_CONCURRENT;
42
47
  this.resultCache = clientConfig.resultCache?.enabled
43
48
  ? new ResultCache({
@@ -505,20 +510,39 @@ export class McpRouter {
505
510
  };
506
511
  this.states.set(server, state);
507
512
  }
513
+ const lastConnectError = state.lastConnectError;
514
+ if (lastConnectError) {
515
+ const withinCooldown = Date.now() - lastConnectError.timestamp < this.connectErrorCooldownMs;
516
+ if (withinCooldown) {
517
+ throw lastConnectError.error;
518
+ }
519
+ state.lastConnectError = undefined;
520
+ }
508
521
  if (state.initPromise) {
509
522
  await state.initPromise;
510
523
  return state;
511
524
  }
512
525
  state.initPromise = (async () => {
513
- if (!state.transport.isConnected()) {
514
- await state.transport.connect();
526
+ try {
527
+ if (!state.transport.isConnected()) {
528
+ await state.transport.connect();
529
+ }
530
+ if (!state.initialized) {
531
+ await initializeProtocol(state.transport, PACKAGE_VERSION);
532
+ state.initialized = true;
533
+ }
534
+ state.lastConnectError = undefined;
535
+ this.markUsed(server);
536
+ await this.enforceMaxConcurrent(server);
515
537
  }
516
- if (!state.initialized) {
517
- await initializeProtocol(state.transport, PACKAGE_VERSION);
518
- state.initialized = true;
538
+ catch (error) {
539
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
540
+ state.lastConnectError = {
541
+ error: normalizedError,
542
+ timestamp: Date.now()
543
+ };
544
+ throw normalizedError;
519
545
  }
520
- this.markUsed(server);
521
- await this.enforceMaxConcurrent(server);
522
546
  })();
523
547
  try {
524
548
  await state.initPromise;
@@ -581,6 +605,9 @@ export class McpRouter {
581
605
  state.idleTimer.unref();
582
606
  }
583
607
  }
608
+ nextRequestId() {
609
+ return nextRequestId(this.requestIdState);
610
+ }
584
611
  createTransport(serverName, serverConfig) {
585
612
  const onReconnected = async () => {
586
613
  const state = this.states.get(serverName);
@@ -594,13 +621,13 @@ export class McpRouter {
594
621
  this.resultCache?.invalidate(`${serverName}:`);
595
622
  };
596
623
  if (serverConfig.transport === "sse") {
597
- return new this.transportRefs.sse(serverConfig, this.clientConfig, this.logger, onReconnected, this.tokenManager);
624
+ return new this.transportRefs.sse(serverConfig, this.clientConfig, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId());
598
625
  }
599
626
  if (serverConfig.transport === "stdio") {
600
- return new this.transportRefs.stdio(serverConfig, this.clientConfig, this.logger, onReconnected);
627
+ return new this.transportRefs.stdio(serverConfig, this.clientConfig, this.logger, onReconnected, () => this.nextRequestId());
601
628
  }
602
629
  if (serverConfig.transport === "streamable-http") {
603
- return new this.transportRefs.streamableHttp(serverConfig, this.clientConfig, this.logger, onReconnected, this.tokenManager);
630
+ return new this.transportRefs.streamableHttp(serverConfig, this.clientConfig, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId());
604
631
  }
605
632
  throw new Error(`Unsupported transport: ${serverConfig.transport}`);
606
633
  }
@@ -3,28 +3,29 @@
3
3
  *
4
4
  * Pipeline order: truncate → sanitize → trust-tag
5
5
  */
6
- // Prompt injection patterns to strip (case-insensitive)
7
- const INJECTION_PATTERNS = [
8
- /ignore\s+(all\s+)?previous\s+instructions/gi,
9
- /ignore\s+(all\s+)?prior\s+instructions/gi,
10
- /disregard\s+(all\s+)?previous\s+instructions/gi,
11
- /you\s+are\s+now\b/gi,
12
- /^system\s*:/gim,
13
- /\bact\s+as\s+(a|an)\s+/gi,
14
- /pretend\s+you\s+are\b/gi,
15
- /from\s+now\s+on\s+you\s+are\b/gi,
16
- /new\s+instructions\s*:/gi,
17
- /override\s+(all\s+)?instructions/gi,
6
+ // Prompt injection pattern sources to strip (compiled per call to avoid RegExp state leakage)
7
+ const INJECTION_PATTERN_SOURCES = [
8
+ "ignore\\s+(all\\s+)?previous\\s+instructions",
9
+ "ignore\\s+(all\\s+)?prior\\s+instructions",
10
+ "disregard\\s+(all\\s+)?previous\\s+instructions",
11
+ "you\\s+are\\s+now\\b",
12
+ "\\bact\\s+as\\s+(a|an)\\s+",
13
+ "pretend\\s+you\\s+are\\b",
14
+ "from\\s+now\\s+on\\s+you\\s+are\\b",
15
+ "new\\s+instructions\\s*:",
16
+ "override\\s+(all\\s+)?instructions",
18
17
  ];
18
+ const INJECTION_PATTERN_MULTILINE_SOURCES = ["^system\\s*:"];
19
19
  function stripHtmlTags(text) {
20
20
  return text.replace(/<[^>]*>/g, "");
21
21
  }
22
22
  function stripInjectionPatterns(text) {
23
23
  let result = text;
24
- for (const pattern of INJECTION_PATTERNS) {
25
- // Reset lastIndex for global regexes
26
- pattern.lastIndex = 0;
27
- result = result.replace(pattern, "");
24
+ for (const source of INJECTION_PATTERN_SOURCES) {
25
+ result = result.replace(new RegExp(source, "gi"), "");
26
+ }
27
+ for (const source of INJECTION_PATTERN_MULTILINE_SOURCES) {
28
+ result = result.replace(new RegExp(source, "gim"), "");
28
29
  }
29
30
  return result;
30
31
  }
@@ -11,6 +11,7 @@ export declare class StandaloneServer {
11
11
  private initialized;
12
12
  private lspMode;
13
13
  private readonly tokenManager;
14
+ private readonly requestIdState;
14
15
  private directTools;
15
16
  private directConnections;
16
17
  constructor(config: BridgeConfig, logger: Logger);
@@ -29,6 +30,7 @@ export declare class StandaloneServer {
29
30
  /** Connect to all backend servers and discover their tools (direct mode). */
30
31
  private discoverDirectTools;
31
32
  private _doDiscovery;
33
+ private nextRequestId;
32
34
  private createTransport;
33
35
  /** Graceful shutdown: disconnect all backend servers. */
34
36
  shutdown(): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import { McpRouter } from "./mcp-router.js";
2
2
  import { fetchToolsList, initializeProtocol, PACKAGE_VERSION } from "./protocol.js";
3
3
  import { pickRegisteredToolName } from "./tool-naming.js";
4
+ import { nextRequestId, } from "./types.js";
4
5
  import { SseTransport } from "./transport-sse.js";
5
6
  import { StdioTransport } from "./transport-stdio.js";
6
7
  import { StreamableHttpTransport } from "./transport-streamable-http.js";
@@ -17,6 +18,7 @@ export class StandaloneServer {
17
18
  initialized = false;
18
19
  lspMode = false;
19
20
  tokenManager;
21
+ requestIdState = { value: 0 };
20
22
  // Direct mode state
21
23
  directTools = [];
22
24
  directConnections = new Map();
@@ -36,25 +38,24 @@ export class StandaloneServer {
36
38
  async startStdio() {
37
39
  const stdin = process.stdin;
38
40
  const stdout = process.stdout;
39
- stdin.setEncoding("utf8");
40
- let buffer = "";
41
+ let buffer = Buffer.alloc(0);
41
42
  // LSP framing state
42
43
  let lspContentLength = -1; // -1 means not in LSP mode for current message
43
44
  let lspHeadersDone = false;
44
45
  stdin.on("data", (chunk) => {
45
- buffer += chunk;
46
+ const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8");
47
+ buffer = Buffer.concat([buffer, chunkBuffer]);
46
48
  // Process buffer in a loop — it may contain multiple messages
47
49
  let progress = true;
48
50
  while (progress) {
49
51
  progress = false;
50
52
  // If we're reading an LSP body, check if we have enough bytes
51
53
  if (lspContentLength >= 0 && lspHeadersDone) {
52
- const bufferBytes = Buffer.byteLength(buffer, "utf8");
53
- if (bufferBytes >= lspContentLength) {
54
+ if (buffer.length >= lspContentLength) {
54
55
  // Extract exactly lspContentLength bytes (LSP spec defines Content-Length in bytes)
55
- const bodyBuffer = Buffer.from(buffer, "utf8").slice(0, lspContentLength);
56
+ const bodyBuffer = buffer.subarray(0, lspContentLength);
56
57
  const body = bodyBuffer.toString("utf8");
57
- buffer = buffer.substring(body.length);
58
+ buffer = buffer.subarray(lspContentLength);
58
59
  lspContentLength = -1;
59
60
  lspHeadersDone = false;
60
61
  const trimmed = body.trim();
@@ -68,15 +69,16 @@ export class StandaloneServer {
68
69
  break;
69
70
  }
70
71
  // Look for complete lines to detect framing
71
- const newlineIdx = buffer.indexOf("\n");
72
+ const newlineIdx = buffer.indexOf(0x0a);
72
73
  if (newlineIdx === -1)
73
74
  break;
74
- const line = buffer.slice(0, newlineIdx);
75
+ const lineBuffer = buffer.subarray(0, newlineIdx);
76
+ const line = lineBuffer.toString("utf8").replace(/\r$/, "");
75
77
  const trimmed = line.trim();
76
78
  // LSP header detection
77
79
  if (lspContentLength >= 0 && !lspHeadersDone) {
78
80
  // We're reading LSP headers — consume until empty line
79
- buffer = buffer.slice(newlineIdx + 1);
81
+ buffer = buffer.subarray(newlineIdx + 1);
80
82
  progress = true;
81
83
  if (trimmed === "") {
82
84
  // End of headers — next read the body
@@ -93,13 +95,13 @@ export class StandaloneServer {
93
95
  if (!isNaN(length) && length > 0) {
94
96
  lspContentLength = length;
95
97
  lspHeadersDone = false;
96
- buffer = buffer.slice(newlineIdx + 1);
98
+ buffer = buffer.subarray(newlineIdx + 1);
97
99
  progress = true;
98
100
  continue;
99
101
  }
100
102
  }
101
103
  // Newline-delimited JSON: consume the line
102
- buffer = buffer.slice(newlineIdx + 1);
104
+ buffer = buffer.subarray(newlineIdx + 1);
103
105
  progress = true;
104
106
  if (!trimmed || !trimmed.startsWith("{"))
105
107
  continue;
@@ -396,6 +398,9 @@ export class StandaloneServer {
396
398
  }
397
399
  }
398
400
  }
401
+ nextRequestId() {
402
+ return nextRequestId(this.requestIdState);
403
+ }
399
404
  createTransport(serverName, serverConfig) {
400
405
  const onReconnected = async () => {
401
406
  this.logger.info(`[mcp-bridge] ${serverName} reconnected, refreshing tools`);
@@ -403,11 +408,11 @@ export class StandaloneServer {
403
408
  };
404
409
  switch (serverConfig.transport) {
405
410
  case "sse":
406
- return new SseTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager);
411
+ return new SseTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId());
407
412
  case "stdio":
408
- return new StdioTransport(serverConfig, this.config, this.logger, onReconnected);
413
+ return new StdioTransport(serverConfig, this.config, this.logger, onReconnected, () => this.nextRequestId());
409
414
  case "streamable-http":
410
- return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager);
415
+ return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId());
411
416
  default:
412
417
  throw new Error(`Unsupported transport: ${serverConfig.transport}`);
413
418
  }
@@ -1,4 +1,4 @@
1
- import { McpTransport, McpRequest, McpResponse, McpServerConfig, McpClientConfig, Logger, JsonRpcMessage } from "./types.js";
1
+ import { McpTransport, McpRequest, McpResponse, McpServerConfig, McpClientConfig, Logger, JsonRpcMessage, RequestIdGenerator } from "./types.js";
2
2
  import type { OAuth2Config, OAuth2TokenManager } from "./oauth2-token-manager.js";
3
3
  export type PendingRequest = {
4
4
  resolve: (value: McpResponse) => void;
@@ -22,12 +22,15 @@ export declare abstract class BaseTransport implements McpTransport {
22
22
  protected reconnectTimer: NodeJS.Timeout | null;
23
23
  protected onReconnected?: () => Promise<void>;
24
24
  protected backoffDelay: number;
25
- constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>);
25
+ private readonly requestIdState;
26
+ private readonly requestIdGenerator;
27
+ constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, requestIdGenerator?: RequestIdGenerator);
26
28
  abstract connect(): Promise<void>;
27
29
  abstract disconnect(): Promise<void>;
28
30
  abstract sendRequest(request: McpRequest): Promise<McpResponse>;
29
31
  abstract sendNotification(notification: any): Promise<void>;
30
32
  isConnected(): boolean;
33
+ protected nextRequestId(): number;
31
34
  /** Human-readable transport name for log messages (e.g. "stdio", "SSE", "streamable-http"). */
32
35
  protected abstract get transportName(): string;
33
36
  /**
@@ -1,3 +1,4 @@
1
+ import { nextRequestId } from "./types.js";
1
2
  import { loadOpenClawDotEnvFallback } from "./config.js";
2
3
  /**
3
4
  * Base class for all MCP transports. Provides shared logic for:
@@ -16,15 +17,21 @@ export class BaseTransport {
16
17
  reconnectTimer = null;
17
18
  onReconnected;
18
19
  backoffDelay = 0;
19
- constructor(config, clientConfig, logger, onReconnected) {
20
+ requestIdState = { value: 0 };
21
+ requestIdGenerator;
22
+ constructor(config, clientConfig, logger, onReconnected, requestIdGenerator) {
20
23
  this.config = config;
21
24
  this.clientConfig = clientConfig;
22
25
  this.logger = logger;
23
26
  this.onReconnected = onReconnected;
27
+ this.requestIdGenerator = requestIdGenerator ?? (() => nextRequestId(this.requestIdState));
24
28
  }
25
29
  isConnected() {
26
30
  return this.connected;
27
31
  }
32
+ nextRequestId() {
33
+ return this.requestIdGenerator();
34
+ }
28
35
  /**
29
36
  * Route an incoming JSON-RPC message to the appropriate handler:
30
37
  * - notifications/tools/list_changed -> trigger tool refresh
@@ -130,7 +137,7 @@ export function resolveEnvVars(value, contextDescription, extraEnv, envFallback)
130
137
  return fallbackVal;
131
138
  }
132
139
  }
133
- if (resolved === undefined) {
140
+ if (resolved === undefined || resolved === "") {
134
141
  throw new Error(`[mcp-bridge] Missing required environment variable "${varName}" while resolving ${contextDescription}`);
135
142
  }
136
143
  return resolved;
@@ -1,4 +1,4 @@
1
- import { Logger, McpClientConfig, McpRequest, McpResponse, McpServerConfig } from "./types.js";
1
+ import { Logger, McpClientConfig, McpRequest, McpResponse, McpServerConfig, RequestIdGenerator } from "./types.js";
2
2
  import { OAuth2TokenManager } from "./oauth2-token-manager.js";
3
3
  import { BaseTransport } from "./transport-base.js";
4
4
  export declare class SseTransport extends BaseTransport {
@@ -8,7 +8,7 @@ export declare class SseTransport extends BaseTransport {
8
8
  private pendingRequestControllers;
9
9
  private readonly tokenManager;
10
10
  protected get transportName(): string;
11
- constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager);
11
+ constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: RequestIdGenerator);
12
12
  connect(): Promise<void>;
13
13
  private _onEndpointReceived;
14
14
  private getBaseHeaders;
@@ -1,4 +1,3 @@
1
- import { nextRequestId } from "./types.js";
2
1
  import { OAuth2TokenManager } from "./oauth2-token-manager.js";
3
2
  import { BaseTransport, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
4
3
  export class SseTransport extends BaseTransport {
@@ -8,8 +7,8 @@ export class SseTransport extends BaseTransport {
8
7
  pendingRequestControllers = new Map();
9
8
  tokenManager;
10
9
  get transportName() { return "SSE"; }
11
- constructor(config, clientConfig, logger, onReconnected, tokenManager) {
12
- super(config, clientConfig, logger, onReconnected);
10
+ constructor(config, clientConfig, logger, onReconnected, tokenManager, requestIdGenerator) {
11
+ super(config, clientConfig, logger, onReconnected, requestIdGenerator);
13
12
  this.tokenManager = tokenManager ?? new OAuth2TokenManager(logger);
14
13
  }
15
14
  async connect() {
@@ -193,7 +192,7 @@ export class SseTransport extends BaseTransport {
193
192
  if (!this.connected || !this.endpointUrl) {
194
193
  throw new Error("SSE transport not connected or no endpoint URL");
195
194
  }
196
- const id = nextRequestId();
195
+ const id = this.nextRequestId();
197
196
  const requestWithId = { ...request, id };
198
197
  return new Promise((resolve, reject) => {
199
198
  const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
@@ -1,5 +1,4 @@
1
1
  import { spawn } from "child_process";
2
- import { nextRequestId } from "./types.js";
3
2
  import { BaseTransport, resolveEnvRecord, resolveArgs } from "./transport-base.js";
4
3
  export class StdioTransport extends BaseTransport {
5
4
  process = null;
@@ -130,8 +129,15 @@ export class StdioTransport extends BaseTransport {
130
129
  this.process.once("error", onProcessError);
131
130
  this.process.once("exit", onProcessExit);
132
131
  timeout = setTimeout(() => {
133
- this.logger.warn(`[mcp-bridge] Stdio startup stdout readiness timed out after ${connectionTimeout}ms; continuing`);
134
- settleResolve();
132
+ const timeoutError = new Error(`Stdio process startup timeout: no data received within ${connectionTimeout}ms`);
133
+ this.logger.warn(`[mcp-bridge] ${timeoutError.message}; terminating unresponsive process`);
134
+ try {
135
+ this.process?.kill("SIGTERM");
136
+ }
137
+ catch {
138
+ // Ignore kill errors and reject with timeout
139
+ }
140
+ settleReject(timeoutError);
135
141
  }, connectionTimeout);
136
142
  });
137
143
  }
@@ -156,7 +162,7 @@ export class StdioTransport extends BaseTransport {
156
162
  if (!this.connected || !this.process?.stdin) {
157
163
  throw new Error("Stdio transport not connected");
158
164
  }
159
- const id = nextRequestId();
165
+ const id = this.nextRequestId();
160
166
  const requestWithId = { ...request, id };
161
167
  return new Promise((resolve, reject) => {
162
168
  const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
@@ -1,4 +1,4 @@
1
- import { Logger, McpClientConfig, McpRequest, McpResponse, McpServerConfig } from "./types.js";
1
+ import { Logger, McpClientConfig, McpRequest, McpResponse, McpServerConfig, RequestIdGenerator } from "./types.js";
2
2
  import { OAuth2TokenManager } from "./oauth2-token-manager.js";
3
3
  import { BaseTransport } from "./transport-base.js";
4
4
  export declare class StreamableHttpTransport extends BaseTransport {
@@ -7,7 +7,7 @@ export declare class StreamableHttpTransport extends BaseTransport {
7
7
  private pendingRequestControllers;
8
8
  private readonly tokenManager;
9
9
  protected get transportName(): string;
10
- constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager);
10
+ constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager, requestIdGenerator?: RequestIdGenerator);
11
11
  connect(): Promise<void>;
12
12
  private getBaseHeaders;
13
13
  private refreshResolvedHeaders;
@@ -1,4 +1,3 @@
1
- import { nextRequestId } from "./types.js";
2
1
  import { OAuth2TokenManager } from "./oauth2-token-manager.js";
3
2
  import { BaseTransport, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
4
3
  export class StreamableHttpTransport extends BaseTransport {
@@ -7,8 +6,8 @@ export class StreamableHttpTransport extends BaseTransport {
7
6
  pendingRequestControllers = new Map();
8
7
  tokenManager;
9
8
  get transportName() { return "streamable-http"; }
10
- constructor(config, clientConfig, logger, onReconnected, tokenManager) {
11
- super(config, clientConfig, logger, onReconnected);
9
+ constructor(config, clientConfig, logger, onReconnected, tokenManager, requestIdGenerator) {
10
+ super(config, clientConfig, logger, onReconnected, requestIdGenerator);
12
11
  this.tokenManager = tokenManager ?? new OAuth2TokenManager(logger);
13
12
  }
14
13
  async connect() {
@@ -68,7 +67,7 @@ export class StreamableHttpTransport extends BaseTransport {
68
67
  if (!this.connected || !this.config.url) {
69
68
  throw new Error("Streamable HTTP transport not connected");
70
69
  }
71
- const id = nextRequestId();
70
+ const id = this.nextRequestId();
72
71
  const requestWithId = { ...request, id };
73
72
  return new Promise((resolve, reject) => {
74
73
  const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
@@ -52,6 +52,7 @@ export interface McpClientConfig {
52
52
  requestTimeoutMs?: number;
53
53
  shutdownTimeoutMs?: number;
54
54
  routerIdleTimeoutMs?: number;
55
+ routerConnectErrorCooldownMs?: number;
55
56
  routerMaxConcurrent?: number;
56
57
  maxBatchSize?: number;
57
58
  schemaCompression?: {
@@ -108,7 +109,11 @@ export interface McpRequest {
108
109
  export interface McpCallRequest extends McpRequest {
109
110
  id: number;
110
111
  }
111
- export declare function nextRequestId(): number;
112
+ export interface RequestIdState {
113
+ value: number;
114
+ }
115
+ export type RequestIdGenerator = () => number;
116
+ export declare function nextRequestId(state: RequestIdState): number;
112
117
  export interface McpResponse {
113
118
  jsonrpc: "2.0";
114
119
  id: number;
package/dist/src/types.js CHANGED
@@ -1,8 +1,7 @@
1
- let globalRequestId = 0;
2
- export function nextRequestId() {
3
- globalRequestId++;
4
- if (globalRequestId >= Number.MAX_SAFE_INTEGER) {
5
- globalRequestId = 1;
1
+ export function nextRequestId(state) {
2
+ state.value++;
3
+ if (state.value >= Number.MAX_SAFE_INTEGER) {
4
+ state.value = 1;
6
5
  }
7
- return globalRequestId;
6
+ return state.value;
8
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "2.1.4",
3
+ "version": "2.5.0",
4
4
  "description": "Standalone MCP server that multiplexes multiple MCP servers into one interface",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -26,7 +26,7 @@
26
26
  "homepage": "https://github.com/AIWerk/mcp-bridge#readme",
27
27
  "repository": {
28
28
  "type": "git",
29
- "url": "https://github.com/AIWerk/mcp-bridge"
29
+ "url": "git+https://github.com/AIWerk/mcp-bridge.git"
30
30
  },
31
31
  "keywords": [
32
32
  "mcp",