@mcp-audit-gateway/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/.github/workflows/npm-publish.yml +21 -0
  2. package/CONTRIBUTING.md +41 -0
  3. package/LICENSE +21 -0
  4. package/README.md +156 -0
  5. package/dist/attestation/audit-log.d.ts +25 -0
  6. package/dist/attestation/audit-log.d.ts.map +1 -0
  7. package/dist/attestation/audit-log.js +95 -0
  8. package/dist/attestation/audit-log.js.map +1 -0
  9. package/dist/attestation/bugfix.test.d.ts +2 -0
  10. package/dist/attestation/bugfix.test.d.ts.map +1 -0
  11. package/dist/attestation/bugfix.test.js +103 -0
  12. package/dist/attestation/bugfix.test.js.map +1 -0
  13. package/dist/attestation/chain.test.d.ts +2 -0
  14. package/dist/attestation/chain.test.d.ts.map +1 -0
  15. package/dist/attestation/chain.test.js +153 -0
  16. package/dist/attestation/chain.test.js.map +1 -0
  17. package/dist/attestation/keygen.d.ts +5 -0
  18. package/dist/attestation/keygen.d.ts.map +1 -0
  19. package/dist/attestation/keygen.js +12 -0
  20. package/dist/attestation/keygen.js.map +1 -0
  21. package/dist/attestation/signer.d.ts +25 -0
  22. package/dist/attestation/signer.d.ts.map +1 -0
  23. package/dist/attestation/signer.js +93 -0
  24. package/dist/attestation/signer.js.map +1 -0
  25. package/dist/attestation/signer.test.d.ts +2 -0
  26. package/dist/attestation/signer.test.d.ts.map +1 -0
  27. package/dist/attestation/signer.test.js +62 -0
  28. package/dist/attestation/signer.test.js.map +1 -0
  29. package/dist/attestation/verify.d.ts +26 -0
  30. package/dist/attestation/verify.d.ts.map +1 -0
  31. package/dist/attestation/verify.js +104 -0
  32. package/dist/attestation/verify.js.map +1 -0
  33. package/dist/attestation/verify.test.d.ts +2 -0
  34. package/dist/attestation/verify.test.d.ts.map +1 -0
  35. package/dist/attestation/verify.test.js +79 -0
  36. package/dist/attestation/verify.test.js.map +1 -0
  37. package/dist/cli.d.ts +3 -0
  38. package/dist/cli.d.ts.map +1 -0
  39. package/dist/cli.js +364 -0
  40. package/dist/cli.js.map +1 -0
  41. package/dist/index.d.ts +12 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +12 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/integration.test.d.ts +2 -0
  46. package/dist/integration.test.d.ts.map +1 -0
  47. package/dist/integration.test.js +88 -0
  48. package/dist/integration.test.js.map +1 -0
  49. package/dist/policy/engine.d.ts +24 -0
  50. package/dist/policy/engine.d.ts.map +1 -0
  51. package/dist/policy/engine.js +130 -0
  52. package/dist/policy/engine.js.map +1 -0
  53. package/dist/policy/engine.test.d.ts +2 -0
  54. package/dist/policy/engine.test.d.ts.map +1 -0
  55. package/dist/policy/engine.test.js +80 -0
  56. package/dist/policy/engine.test.js.map +1 -0
  57. package/dist/proxy/gateway.d.ts +54 -0
  58. package/dist/proxy/gateway.d.ts.map +1 -0
  59. package/dist/proxy/gateway.js +245 -0
  60. package/dist/proxy/gateway.js.map +1 -0
  61. package/dist/proxy/gateway.test.d.ts +2 -0
  62. package/dist/proxy/gateway.test.d.ts.map +1 -0
  63. package/dist/proxy/gateway.test.js +93 -0
  64. package/dist/proxy/gateway.test.js.map +1 -0
  65. package/dist/proxy/mcp-server-adapter.d.ts +21 -0
  66. package/dist/proxy/mcp-server-adapter.d.ts.map +1 -0
  67. package/dist/proxy/mcp-server-adapter.js +98 -0
  68. package/dist/proxy/mcp-server-adapter.js.map +1 -0
  69. package/dist/proxy/mcp-server-adapter.test.d.ts +2 -0
  70. package/dist/proxy/mcp-server-adapter.test.d.ts.map +1 -0
  71. package/dist/proxy/mcp-server-adapter.test.js +160 -0
  72. package/dist/proxy/mcp-server-adapter.test.js.map +1 -0
  73. package/dist/proxy/upstream-manager.d.ts +49 -0
  74. package/dist/proxy/upstream-manager.d.ts.map +1 -0
  75. package/dist/proxy/upstream-manager.js +246 -0
  76. package/dist/proxy/upstream-manager.js.map +1 -0
  77. package/dist/proxy/upstream-manager.test.d.ts +2 -0
  78. package/dist/proxy/upstream-manager.test.d.ts.map +1 -0
  79. package/dist/proxy/upstream-manager.test.js +413 -0
  80. package/dist/proxy/upstream-manager.test.js.map +1 -0
  81. package/dist/telemetry/logger.d.ts +26 -0
  82. package/dist/telemetry/logger.d.ts.map +1 -0
  83. package/dist/telemetry/logger.js +48 -0
  84. package/dist/telemetry/logger.js.map +1 -0
  85. package/dist/telemetry/logger.test.d.ts +2 -0
  86. package/dist/telemetry/logger.test.d.ts.map +1 -0
  87. package/dist/telemetry/logger.test.js +124 -0
  88. package/dist/telemetry/logger.test.js.map +1 -0
  89. package/dist/telemetry/metrics.d.ts +41 -0
  90. package/dist/telemetry/metrics.d.ts.map +1 -0
  91. package/dist/telemetry/metrics.js +64 -0
  92. package/dist/telemetry/metrics.js.map +1 -0
  93. package/dist/telemetry/metrics.test.d.ts +2 -0
  94. package/dist/telemetry/metrics.test.d.ts.map +1 -0
  95. package/dist/telemetry/metrics.test.js +93 -0
  96. package/dist/telemetry/metrics.test.js.map +1 -0
  97. package/dist/telemetry/tracer.d.ts +16 -0
  98. package/dist/telemetry/tracer.d.ts.map +1 -0
  99. package/dist/telemetry/tracer.js +71 -0
  100. package/dist/telemetry/tracer.js.map +1 -0
  101. package/dist/types.d.ts +477 -0
  102. package/dist/types.d.ts.map +1 -0
  103. package/dist/types.js +68 -0
  104. package/dist/types.js.map +1 -0
  105. package/dist/wrap/proxy.d.ts +2 -0
  106. package/dist/wrap/proxy.d.ts.map +1 -0
  107. package/dist/wrap/proxy.js +152 -0
  108. package/dist/wrap/proxy.js.map +1 -0
  109. package/dist/wrap/proxy.test.d.ts +2 -0
  110. package/dist/wrap/proxy.test.d.ts.map +1 -0
  111. package/dist/wrap/proxy.test.js +155 -0
  112. package/dist/wrap/proxy.test.js.map +1 -0
  113. package/examples/README.md +33 -0
  114. package/examples/claude-desktop.json +50 -0
  115. package/examples/multi-agent-production.json +72 -0
  116. package/gateway.config.example.json +61 -0
  117. package/package.json +49 -0
  118. package/src/attestation/audit-log.ts +126 -0
  119. package/src/attestation/bugfix.test.ts +113 -0
  120. package/src/attestation/chain.test.ts +188 -0
  121. package/src/attestation/keygen.ts +18 -0
  122. package/src/attestation/signer.test.ts +75 -0
  123. package/src/attestation/signer.ts +105 -0
  124. package/src/attestation/verify.test.ts +86 -0
  125. package/src/attestation/verify.ts +131 -0
  126. package/src/cli.ts +407 -0
  127. package/src/index.ts +21 -0
  128. package/src/integration.test.ts +108 -0
  129. package/src/policy/engine.test.ts +95 -0
  130. package/src/policy/engine.ts +162 -0
  131. package/src/proxy/gateway.test.ts +108 -0
  132. package/src/proxy/gateway.ts +328 -0
  133. package/src/proxy/mcp-server-adapter.test.ts +197 -0
  134. package/src/proxy/mcp-server-adapter.ts +140 -0
  135. package/src/proxy/upstream-manager.test.ts +534 -0
  136. package/src/proxy/upstream-manager.ts +326 -0
  137. package/src/telemetry/logger.test.ts +139 -0
  138. package/src/telemetry/logger.ts +72 -0
  139. package/src/telemetry/metrics.test.ts +125 -0
  140. package/src/telemetry/metrics.ts +83 -0
  141. package/src/telemetry/tracer.ts +90 -0
  142. package/src/types.ts +113 -0
  143. package/src/wrap/proxy.test.ts +175 -0
  144. package/src/wrap/proxy.ts +192 -0
  145. package/tsconfig.json +18 -0
@@ -0,0 +1,534 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import {
3
+ UpstreamManager,
4
+ UpstreamUnavailableError,
5
+ ConnectionTimeoutError,
6
+ } from "./upstream-manager.js";
7
+ import type { UpstreamConfig } from "../types.js";
8
+
9
+ // Mock the MCP SDK modules
10
+ vi.mock("@modelcontextprotocol/sdk/client/index.js", () => {
11
+ return {
12
+ Client: vi.fn().mockImplementation(() => ({
13
+ connect: vi.fn().mockResolvedValue(undefined),
14
+ listTools: vi.fn().mockResolvedValue({
15
+ tools: [
16
+ { name: "tool_a", description: "Tool A" },
17
+ { name: "tool_b", description: "Tool B" },
18
+ ],
19
+ }),
20
+ callTool: vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] }),
21
+ ping: vi.fn().mockResolvedValue(undefined),
22
+ close: vi.fn().mockResolvedValue(undefined),
23
+ })),
24
+ };
25
+ });
26
+
27
+ vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({
28
+ StdioClientTransport: vi.fn().mockImplementation(() => ({})),
29
+ }));
30
+
31
+ vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
32
+ StreamableHTTPClientTransport: vi.fn().mockImplementation(() => ({})),
33
+ }));
34
+
35
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
36
+
37
+ function createMockClient() {
38
+ const mockClient = {
39
+ connect: vi.fn().mockResolvedValue(undefined),
40
+ listTools: vi.fn().mockResolvedValue({
41
+ tools: [
42
+ { name: "tool_a", description: "Tool A" },
43
+ { name: "tool_b", description: "Tool B" },
44
+ ],
45
+ }),
46
+ callTool: vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] }),
47
+ ping: vi.fn().mockResolvedValue(undefined),
48
+ close: vi.fn().mockResolvedValue(undefined),
49
+ };
50
+ return mockClient;
51
+ }
52
+
53
+ const testUpstream: UpstreamConfig = {
54
+ name: "test-upstream",
55
+ namespace: "test",
56
+ transport: { type: "stdio", command: "echo", args: [] },
57
+ };
58
+
59
+ describe("UpstreamManager", () => {
60
+ let manager: UpstreamManager;
61
+ let mockClient: ReturnType<typeof createMockClient>;
62
+
63
+ beforeEach(() => {
64
+ vi.useFakeTimers();
65
+ manager = new UpstreamManager();
66
+ mockClient = createMockClient();
67
+ vi.mocked(Client).mockImplementation(() => mockClient as any);
68
+ });
69
+
70
+ afterEach(async () => {
71
+ await manager.disconnectAll();
72
+ vi.useRealTimers();
73
+ vi.clearAllMocks();
74
+ });
75
+
76
+ describe("connect", () => {
77
+ it("connects to an upstream and lists tools", async () => {
78
+ const conn = await manager.connect(testUpstream);
79
+ expect(conn.status.status).toBe("healthy");
80
+ expect(conn.tools).toHaveLength(2);
81
+ expect(conn.consecutiveFailures).toBe(0);
82
+ });
83
+
84
+ it("times out if connect takes too long", async () => {
85
+ mockClient.connect.mockImplementation(
86
+ () => new Promise((resolve) => setTimeout(resolve, 20_000)),
87
+ );
88
+
89
+ const promise = manager.connect(testUpstream, 100);
90
+ vi.advanceTimersByTime(101);
91
+ await expect(promise).rejects.toThrow(ConnectionTimeoutError);
92
+ await expect(promise).rejects.toThrow("timed out after 100ms");
93
+ });
94
+
95
+ it("times out if listTools takes too long", async () => {
96
+ mockClient.listTools.mockImplementation(
97
+ () => new Promise((resolve) => setTimeout(resolve, 20_000)),
98
+ );
99
+
100
+ const promise = manager.connect(testUpstream, 100);
101
+ // Attach rejection handler before advancing timers to prevent unhandled rejection
102
+ const expectation = expect(promise).rejects.toThrow(ConnectionTimeoutError);
103
+ await vi.advanceTimersByTimeAsync(101);
104
+ await expectation;
105
+ });
106
+
107
+ it("uses default timeout of 10s", async () => {
108
+ mockClient.connect.mockImplementation(
109
+ () => new Promise((resolve) => setTimeout(resolve, 20_000)),
110
+ );
111
+
112
+ const promise = manager.connect(testUpstream);
113
+ vi.advanceTimersByTime(10_001);
114
+ await expect(promise).rejects.toThrow(ConnectionTimeoutError);
115
+ await expect(promise).rejects.toThrow("10000ms");
116
+ });
117
+ });
118
+
119
+ describe("callTool", () => {
120
+ it("calls tool successfully and resets failure count", async () => {
121
+ await manager.connect(testUpstream);
122
+ const result = await manager.callTool("test-upstream", "tool_a", {});
123
+ expect(result).toEqual({ content: [{ type: "text", text: "ok" }] });
124
+ });
125
+
126
+ it("throws UpstreamUnavailableError when upstream is unavailable", async () => {
127
+ await manager.connect(testUpstream);
128
+ const conn = manager.getConnection("test-upstream")!;
129
+ conn.status.status = "unavailable";
130
+ conn.status.unavailableReason = "Health check failures";
131
+
132
+ await expect(
133
+ manager.callTool("test-upstream", "tool_a", {}),
134
+ ).rejects.toThrow(UpstreamUnavailableError);
135
+
136
+ await expect(
137
+ manager.callTool("test-upstream", "tool_a", {}),
138
+ ).rejects.toThrow('Upstream "test-upstream" is unavailable');
139
+ });
140
+
141
+ it("marks upstream degraded after 1-2 failures", async () => {
142
+ await manager.connect(testUpstream);
143
+ mockClient.callTool.mockRejectedValue(new Error("call failed"));
144
+
145
+ await expect(manager.callTool("test-upstream", "tool_a", {})).rejects.toThrow();
146
+ const conn = manager.getConnection("test-upstream")!;
147
+ expect(conn.status.status).toBe("degraded");
148
+ expect(conn.consecutiveFailures).toBe(1);
149
+ });
150
+
151
+ it("marks upstream unavailable after 3 consecutive failures", async () => {
152
+ await manager.connect(testUpstream);
153
+ mockClient.callTool.mockRejectedValue(new Error("call failed"));
154
+
155
+ for (let i = 0; i < 3; i++) {
156
+ await expect(manager.callTool("test-upstream", "tool_a", {})).rejects.toThrow();
157
+ }
158
+
159
+ const conn = manager.getConnection("test-upstream")!;
160
+ expect(conn.status.status).toBe("unavailable");
161
+ expect(conn.consecutiveFailures).toBe(3);
162
+ });
163
+
164
+ it("resets consecutive failures on successful call", async () => {
165
+ await manager.connect(testUpstream);
166
+
167
+ // Fail twice
168
+ mockClient.callTool.mockRejectedValueOnce(new Error("fail 1"));
169
+ mockClient.callTool.mockRejectedValueOnce(new Error("fail 2"));
170
+
171
+ await expect(manager.callTool("test-upstream", "tool_a", {})).rejects.toThrow();
172
+ await expect(manager.callTool("test-upstream", "tool_a", {})).rejects.toThrow();
173
+
174
+ const conn = manager.getConnection("test-upstream")!;
175
+ expect(conn.consecutiveFailures).toBe(2);
176
+
177
+ // Succeed resets counter
178
+ mockClient.callTool.mockResolvedValueOnce({ content: [] });
179
+ await manager.callTool("test-upstream", "tool_a", {});
180
+ expect(conn.consecutiveFailures).toBe(0);
181
+ expect(conn.status.status).toBe("healthy");
182
+ });
183
+ });
184
+
185
+ describe("health check transitions", () => {
186
+ it("transitions healthy -> degraded on first ping failure", async () => {
187
+ await manager.connect(testUpstream);
188
+ mockClient.ping.mockRejectedValue(new Error("ping timeout"));
189
+
190
+ manager.startHealthChecks(1000);
191
+ await vi.advanceTimersByTimeAsync(1000);
192
+
193
+ const conn = manager.getConnection("test-upstream")!;
194
+ expect(conn.status.status).toBe("degraded");
195
+ expect(conn.consecutiveFailures).toBe(1);
196
+ });
197
+
198
+ it("transitions degraded -> unavailable after 3 consecutive ping failures", async () => {
199
+ await manager.connect(testUpstream);
200
+ mockClient.ping.mockRejectedValue(new Error("ping timeout"));
201
+
202
+ manager.startHealthChecks(1000);
203
+
204
+ // Tick 3 health check cycles
205
+ await vi.advanceTimersByTimeAsync(1000);
206
+ await vi.advanceTimersByTimeAsync(1000);
207
+ await vi.advanceTimersByTimeAsync(1000);
208
+
209
+ const conn = manager.getConnection("test-upstream")!;
210
+ expect(conn.status.status).toBe("unavailable");
211
+ expect(conn.consecutiveFailures).toBe(3);
212
+ });
213
+
214
+ it("transitions degraded -> healthy on successful ping", async () => {
215
+ await manager.connect(testUpstream);
216
+ const conn = manager.getConnection("test-upstream")!;
217
+
218
+ // Manually set degraded state
219
+ conn.status.status = "degraded";
220
+ conn.consecutiveFailures = 2;
221
+
222
+ mockClient.ping.mockResolvedValue(undefined);
223
+ manager.startHealthChecks(1000);
224
+ await vi.advanceTimersByTimeAsync(1000);
225
+
226
+ expect(conn.status.status).toBe("healthy");
227
+ expect(conn.consecutiveFailures).toBe(0);
228
+ });
229
+
230
+ it("skips health checks for unavailable upstreams", async () => {
231
+ await manager.connect(testUpstream);
232
+ const conn = manager.getConnection("test-upstream")!;
233
+ conn.status.status = "unavailable";
234
+
235
+ manager.startHealthChecks(1000);
236
+ await vi.advanceTimersByTimeAsync(1000);
237
+
238
+ // ping should not be called for unavailable upstreams
239
+ expect(mockClient.ping).not.toHaveBeenCalled();
240
+ });
241
+
242
+ it("full cycle: healthy -> degraded -> unavailable -> healthy (via reconnect)", async () => {
243
+ await manager.connect(testUpstream);
244
+ const conn = manager.getConnection("test-upstream")!;
245
+ expect(conn.status.status).toBe("healthy");
246
+
247
+ // Degrade
248
+ mockClient.ping.mockRejectedValue(new Error("down"));
249
+ manager.startHealthChecks(1000);
250
+ await vi.advanceTimersByTimeAsync(1000);
251
+ expect(conn.status.status).toBe("degraded");
252
+
253
+ // Become unavailable
254
+ await vi.advanceTimersByTimeAsync(1000);
255
+ await vi.advanceTimersByTimeAsync(1000);
256
+ expect(conn.status.status).toBe("unavailable");
257
+
258
+ // Recover via reconnect
259
+ mockClient.connect.mockResolvedValue(undefined);
260
+ mockClient.listTools.mockResolvedValue({
261
+ tools: [{ name: "tool_a", description: "Tool A" }],
262
+ });
263
+
264
+ await manager.reconnect("test-upstream");
265
+ expect(conn.status.status).toBe("healthy");
266
+ expect(conn.consecutiveFailures).toBe(0);
267
+ });
268
+ });
269
+
270
+ describe("reconnection backoff", () => {
271
+ it("schedules reconnection with exponential backoff on unavailable", async () => {
272
+ await manager.connect(testUpstream);
273
+ mockClient.callTool.mockRejectedValue(new Error("fail"));
274
+
275
+ // Trigger 3 failures to become unavailable
276
+ for (let i = 0; i < 3; i++) {
277
+ await expect(manager.callTool("test-upstream", "tool_a", {})).rejects.toThrow();
278
+ }
279
+
280
+ const conn = manager.getConnection("test-upstream")!;
281
+ expect(conn.status.status).toBe("unavailable");
282
+ expect(conn.reconnectTimer).toBeDefined();
283
+ });
284
+
285
+ it("uses 1s backoff on first reconnect attempt", async () => {
286
+ await manager.connect(testUpstream);
287
+ const conn = manager.getConnection("test-upstream")!;
288
+
289
+ // Make unavailable (triggers first scheduled reconnect)
290
+ mockClient.callTool.mockRejectedValue(new Error("fail"));
291
+ for (let i = 0; i < 3; i++) {
292
+ await expect(manager.callTool("test-upstream", "tool_a", {})).rejects.toThrow();
293
+ }
294
+
295
+ // Mock reconnect to succeed
296
+ mockClient.connect.mockResolvedValue(undefined);
297
+ mockClient.listTools.mockResolvedValue({ tools: [] });
298
+
299
+ // Advance less than 1s - should not reconnect yet
300
+ await vi.advanceTimersByTimeAsync(999);
301
+ expect(conn.status.status).toBe("unavailable");
302
+
303
+ // Advance to 1s - should trigger reconnect
304
+ await vi.advanceTimersByTimeAsync(1);
305
+ expect(conn.status.status).toBe("healthy");
306
+ });
307
+
308
+ it("doubles backoff on consecutive reconnect failures", async () => {
309
+ await manager.connect(testUpstream);
310
+
311
+ // Make unavailable
312
+ mockClient.callTool.mockRejectedValue(new Error("fail"));
313
+ for (let i = 0; i < 3; i++) {
314
+ await expect(manager.callTool("test-upstream", "tool_a", {})).rejects.toThrow();
315
+ }
316
+
317
+ const conn = manager.getConnection("test-upstream")!;
318
+ expect(conn.status.status).toBe("unavailable");
319
+
320
+ // First reconnect attempt fails after 1s
321
+ mockClient.connect.mockRejectedValue(new Error("still down"));
322
+ await vi.advanceTimersByTimeAsync(1000);
323
+ expect(conn.reconnectAttempts).toBe(1);
324
+ expect(conn.status.status).toBe("unavailable");
325
+
326
+ // Second reconnect attempt should be at 2s
327
+ await vi.advanceTimersByTimeAsync(1999);
328
+ expect(conn.reconnectAttempts).toBe(1);
329
+ await vi.advanceTimersByTimeAsync(1);
330
+ expect(conn.reconnectAttempts).toBe(2);
331
+
332
+ // Third reconnect attempt should be at 4s
333
+ await vi.advanceTimersByTimeAsync(3999);
334
+ expect(conn.reconnectAttempts).toBe(2);
335
+ await vi.advanceTimersByTimeAsync(1);
336
+ expect(conn.reconnectAttempts).toBe(3);
337
+ });
338
+
339
+ it("caps backoff at 30s", async () => {
340
+ await manager.connect(testUpstream);
341
+ const conn = manager.getConnection("test-upstream")!;
342
+
343
+ // Make unavailable
344
+ mockClient.callTool.mockRejectedValue(new Error("fail"));
345
+ for (let i = 0; i < 3; i++) {
346
+ await expect(manager.callTool("test-upstream", "tool_a", {})).rejects.toThrow();
347
+ }
348
+
349
+ // Mock reconnect to keep failing
350
+ mockClient.connect.mockRejectedValue(new Error("still down"));
351
+
352
+ // Advance through several reconnect attempts to exceed 30s cap
353
+ // Attempts: 1s, 2s, 4s, 8s, 16s, 30s (capped), 30s (capped)
354
+ await vi.advanceTimersByTimeAsync(1000); // attempt 1 at 1s
355
+ expect(conn.reconnectAttempts).toBe(1);
356
+
357
+ await vi.advanceTimersByTimeAsync(2000); // attempt 2 at 2s
358
+ expect(conn.reconnectAttempts).toBe(2);
359
+
360
+ await vi.advanceTimersByTimeAsync(4000); // attempt 3 at 4s
361
+ expect(conn.reconnectAttempts).toBe(3);
362
+
363
+ await vi.advanceTimersByTimeAsync(8000); // attempt 4 at 8s
364
+ expect(conn.reconnectAttempts).toBe(4);
365
+
366
+ await vi.advanceTimersByTimeAsync(16000); // attempt 5 at 16s
367
+ expect(conn.reconnectAttempts).toBe(5);
368
+
369
+ // Next should be capped at 30s (2^5 = 32s > 30s)
370
+ await vi.advanceTimersByTimeAsync(29999);
371
+ expect(conn.reconnectAttempts).toBe(5);
372
+ await vi.advanceTimersByTimeAsync(1);
373
+ expect(conn.reconnectAttempts).toBe(6);
374
+ });
375
+
376
+ it("resets reconnect attempts on successful reconnect", async () => {
377
+ await manager.connect(testUpstream);
378
+
379
+ // Make unavailable
380
+ mockClient.callTool.mockRejectedValue(new Error("fail"));
381
+ for (let i = 0; i < 3; i++) {
382
+ await expect(manager.callTool("test-upstream", "tool_a", {})).rejects.toThrow();
383
+ }
384
+
385
+ const conn = manager.getConnection("test-upstream")!;
386
+
387
+ // First reconnect fails
388
+ mockClient.connect.mockRejectedValue(new Error("still down"));
389
+ await vi.advanceTimersByTimeAsync(1000);
390
+ expect(conn.reconnectAttempts).toBe(1);
391
+
392
+ // Second reconnect succeeds
393
+ mockClient.connect.mockResolvedValue(undefined);
394
+ mockClient.listTools.mockResolvedValue({ tools: [] });
395
+ await vi.advanceTimersByTimeAsync(2000);
396
+ expect(conn.reconnectAttempts).toBe(0);
397
+ expect(conn.status.status).toBe("healthy");
398
+ });
399
+ });
400
+
401
+ describe("timeout behavior", () => {
402
+ it("throws ConnectionTimeoutError with upstream name and timeout value", async () => {
403
+ mockClient.connect.mockImplementation(
404
+ () => new Promise((resolve) => setTimeout(resolve, 60_000)),
405
+ );
406
+
407
+ const promise = manager.connect(testUpstream, 5000);
408
+ vi.advanceTimersByTime(5001);
409
+
410
+ await expect(promise).rejects.toThrow(ConnectionTimeoutError);
411
+ try {
412
+ vi.advanceTimersByTime(5001);
413
+ await promise;
414
+ } catch (err) {
415
+ expect(err).toBeInstanceOf(ConnectionTimeoutError);
416
+ expect((err as ConnectionTimeoutError).upstreamName).toBe("test-upstream");
417
+ expect((err as ConnectionTimeoutError).timeoutMs).toBe(5000);
418
+ }
419
+ });
420
+
421
+ it("does not mark as unavailable if connect times out (no prior connection)", async () => {
422
+ mockClient.connect.mockImplementation(
423
+ () => new Promise((resolve) => setTimeout(resolve, 60_000)),
424
+ );
425
+
426
+ const promise = manager.connect(testUpstream, 100);
427
+ vi.advanceTimersByTime(101);
428
+ await expect(promise).rejects.toThrow();
429
+
430
+ // No connection was stored
431
+ expect(manager.getConnection("test-upstream")).toBeUndefined();
432
+ });
433
+
434
+ it("connect timeout is configurable per-call", async () => {
435
+ mockClient.connect.mockImplementation(
436
+ () => new Promise((resolve) => setTimeout(resolve, 5000)),
437
+ );
438
+
439
+ // Short timeout fails
440
+ const shortPromise = manager.connect(testUpstream, 50);
441
+ vi.advanceTimersByTime(51);
442
+ await expect(shortPromise).rejects.toThrow(ConnectionTimeoutError);
443
+
444
+ // Long timeout succeeds
445
+ const longPromise = manager.connect(testUpstream, 10_000);
446
+ vi.advanceTimersByTime(5000);
447
+ const conn = await longPromise;
448
+ expect(conn.status.status).toBe("healthy");
449
+ });
450
+ });
451
+
452
+ describe("graceful degradation", () => {
453
+ it("returns clear error with upstream name when upstream is unavailable", async () => {
454
+ await manager.connect(testUpstream);
455
+ const conn = manager.getConnection("test-upstream")!;
456
+ conn.status.status = "unavailable";
457
+ conn.status.unavailableReason = "Connection lost after 3 ping failures";
458
+
459
+ try {
460
+ await manager.callTool("test-upstream", "tool_a", {});
461
+ expect.fail("Should have thrown");
462
+ } catch (err) {
463
+ expect(err).toBeInstanceOf(UpstreamUnavailableError);
464
+ const error = err as UpstreamUnavailableError;
465
+ expect(error.upstreamName).toBe("test-upstream");
466
+ expect(error.reason).toBe("Connection lost after 3 ping failures");
467
+ expect(error.message).toContain("test-upstream");
468
+ expect(error.message).toContain("unavailable");
469
+ }
470
+ });
471
+
472
+ it("does not attempt tool call when upstream is unavailable", async () => {
473
+ await manager.connect(testUpstream);
474
+ const conn = manager.getConnection("test-upstream")!;
475
+ conn.status.status = "unavailable";
476
+
477
+ await expect(manager.callTool("test-upstream", "tool_a", {})).rejects.toThrow();
478
+ expect(mockClient.callTool).not.toHaveBeenCalled();
479
+ });
480
+
481
+ it("allows calls after upstream recovers", async () => {
482
+ await manager.connect(testUpstream);
483
+ const conn = manager.getConnection("test-upstream")!;
484
+
485
+ // Make unavailable
486
+ conn.status.status = "unavailable";
487
+
488
+ // Reconnect successfully
489
+ mockClient.connect.mockResolvedValue(undefined);
490
+ mockClient.listTools.mockResolvedValue({ tools: [{ name: "tool_a" }] });
491
+ await manager.reconnect("test-upstream");
492
+
493
+ expect(conn.status.status).toBe("healthy");
494
+
495
+ // Call should work now
496
+ mockClient.callTool.mockResolvedValue({ content: [] });
497
+ const result = await manager.callTool("test-upstream", "tool_a", {});
498
+ expect(result).toEqual({ content: [] });
499
+ });
500
+ });
501
+
502
+ describe("stopHealthChecks", () => {
503
+ it("stops the health check interval", async () => {
504
+ await manager.connect(testUpstream);
505
+ mockClient.ping.mockRejectedValue(new Error("down"));
506
+
507
+ manager.startHealthChecks(1000);
508
+ await vi.advanceTimersByTimeAsync(1000);
509
+
510
+ const conn = manager.getConnection("test-upstream")!;
511
+ expect(conn.consecutiveFailures).toBe(1);
512
+
513
+ manager.stopHealthChecks();
514
+ await vi.advanceTimersByTimeAsync(5000);
515
+
516
+ // Failures should not have increased
517
+ expect(conn.consecutiveFailures).toBe(1);
518
+ });
519
+ });
520
+
521
+ describe("disconnectAll", () => {
522
+ it("cleans up all timers and connections", async () => {
523
+ await manager.connect(testUpstream);
524
+ manager.startHealthChecks(1000);
525
+
526
+ const conn = manager.getConnection("test-upstream")!;
527
+ conn.status.status = "unavailable";
528
+
529
+ await manager.disconnectAll();
530
+ expect(manager.getConnection("test-upstream")).toBeUndefined();
531
+ expect(manager.getAllStatuses()).toHaveLength(0);
532
+ });
533
+ });
534
+ });