@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.
- package/.github/workflows/npm-publish.yml +21 -0
- package/CONTRIBUTING.md +41 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/attestation/audit-log.d.ts +25 -0
- package/dist/attestation/audit-log.d.ts.map +1 -0
- package/dist/attestation/audit-log.js +95 -0
- package/dist/attestation/audit-log.js.map +1 -0
- package/dist/attestation/bugfix.test.d.ts +2 -0
- package/dist/attestation/bugfix.test.d.ts.map +1 -0
- package/dist/attestation/bugfix.test.js +103 -0
- package/dist/attestation/bugfix.test.js.map +1 -0
- package/dist/attestation/chain.test.d.ts +2 -0
- package/dist/attestation/chain.test.d.ts.map +1 -0
- package/dist/attestation/chain.test.js +153 -0
- package/dist/attestation/chain.test.js.map +1 -0
- package/dist/attestation/keygen.d.ts +5 -0
- package/dist/attestation/keygen.d.ts.map +1 -0
- package/dist/attestation/keygen.js +12 -0
- package/dist/attestation/keygen.js.map +1 -0
- package/dist/attestation/signer.d.ts +25 -0
- package/dist/attestation/signer.d.ts.map +1 -0
- package/dist/attestation/signer.js +93 -0
- package/dist/attestation/signer.js.map +1 -0
- package/dist/attestation/signer.test.d.ts +2 -0
- package/dist/attestation/signer.test.d.ts.map +1 -0
- package/dist/attestation/signer.test.js +62 -0
- package/dist/attestation/signer.test.js.map +1 -0
- package/dist/attestation/verify.d.ts +26 -0
- package/dist/attestation/verify.d.ts.map +1 -0
- package/dist/attestation/verify.js +104 -0
- package/dist/attestation/verify.js.map +1 -0
- package/dist/attestation/verify.test.d.ts +2 -0
- package/dist/attestation/verify.test.d.ts.map +1 -0
- package/dist/attestation/verify.test.js +79 -0
- package/dist/attestation/verify.test.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +364 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.test.d.ts +2 -0
- package/dist/integration.test.d.ts.map +1 -0
- package/dist/integration.test.js +88 -0
- package/dist/integration.test.js.map +1 -0
- package/dist/policy/engine.d.ts +24 -0
- package/dist/policy/engine.d.ts.map +1 -0
- package/dist/policy/engine.js +130 -0
- package/dist/policy/engine.js.map +1 -0
- package/dist/policy/engine.test.d.ts +2 -0
- package/dist/policy/engine.test.d.ts.map +1 -0
- package/dist/policy/engine.test.js +80 -0
- package/dist/policy/engine.test.js.map +1 -0
- package/dist/proxy/gateway.d.ts +54 -0
- package/dist/proxy/gateway.d.ts.map +1 -0
- package/dist/proxy/gateway.js +245 -0
- package/dist/proxy/gateway.js.map +1 -0
- package/dist/proxy/gateway.test.d.ts +2 -0
- package/dist/proxy/gateway.test.d.ts.map +1 -0
- package/dist/proxy/gateway.test.js +93 -0
- package/dist/proxy/gateway.test.js.map +1 -0
- package/dist/proxy/mcp-server-adapter.d.ts +21 -0
- package/dist/proxy/mcp-server-adapter.d.ts.map +1 -0
- package/dist/proxy/mcp-server-adapter.js +98 -0
- package/dist/proxy/mcp-server-adapter.js.map +1 -0
- package/dist/proxy/mcp-server-adapter.test.d.ts +2 -0
- package/dist/proxy/mcp-server-adapter.test.d.ts.map +1 -0
- package/dist/proxy/mcp-server-adapter.test.js +160 -0
- package/dist/proxy/mcp-server-adapter.test.js.map +1 -0
- package/dist/proxy/upstream-manager.d.ts +49 -0
- package/dist/proxy/upstream-manager.d.ts.map +1 -0
- package/dist/proxy/upstream-manager.js +246 -0
- package/dist/proxy/upstream-manager.js.map +1 -0
- package/dist/proxy/upstream-manager.test.d.ts +2 -0
- package/dist/proxy/upstream-manager.test.d.ts.map +1 -0
- package/dist/proxy/upstream-manager.test.js +413 -0
- package/dist/proxy/upstream-manager.test.js.map +1 -0
- package/dist/telemetry/logger.d.ts +26 -0
- package/dist/telemetry/logger.d.ts.map +1 -0
- package/dist/telemetry/logger.js +48 -0
- package/dist/telemetry/logger.js.map +1 -0
- package/dist/telemetry/logger.test.d.ts +2 -0
- package/dist/telemetry/logger.test.d.ts.map +1 -0
- package/dist/telemetry/logger.test.js +124 -0
- package/dist/telemetry/logger.test.js.map +1 -0
- package/dist/telemetry/metrics.d.ts +41 -0
- package/dist/telemetry/metrics.d.ts.map +1 -0
- package/dist/telemetry/metrics.js +64 -0
- package/dist/telemetry/metrics.js.map +1 -0
- package/dist/telemetry/metrics.test.d.ts +2 -0
- package/dist/telemetry/metrics.test.d.ts.map +1 -0
- package/dist/telemetry/metrics.test.js +93 -0
- package/dist/telemetry/metrics.test.js.map +1 -0
- package/dist/telemetry/tracer.d.ts +16 -0
- package/dist/telemetry/tracer.d.ts.map +1 -0
- package/dist/telemetry/tracer.js +71 -0
- package/dist/telemetry/tracer.js.map +1 -0
- package/dist/types.d.ts +477 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +68 -0
- package/dist/types.js.map +1 -0
- package/dist/wrap/proxy.d.ts +2 -0
- package/dist/wrap/proxy.d.ts.map +1 -0
- package/dist/wrap/proxy.js +152 -0
- package/dist/wrap/proxy.js.map +1 -0
- package/dist/wrap/proxy.test.d.ts +2 -0
- package/dist/wrap/proxy.test.d.ts.map +1 -0
- package/dist/wrap/proxy.test.js +155 -0
- package/dist/wrap/proxy.test.js.map +1 -0
- package/examples/README.md +33 -0
- package/examples/claude-desktop.json +50 -0
- package/examples/multi-agent-production.json +72 -0
- package/gateway.config.example.json +61 -0
- package/package.json +49 -0
- package/src/attestation/audit-log.ts +126 -0
- package/src/attestation/bugfix.test.ts +113 -0
- package/src/attestation/chain.test.ts +188 -0
- package/src/attestation/keygen.ts +18 -0
- package/src/attestation/signer.test.ts +75 -0
- package/src/attestation/signer.ts +105 -0
- package/src/attestation/verify.test.ts +86 -0
- package/src/attestation/verify.ts +131 -0
- package/src/cli.ts +407 -0
- package/src/index.ts +21 -0
- package/src/integration.test.ts +108 -0
- package/src/policy/engine.test.ts +95 -0
- package/src/policy/engine.ts +162 -0
- package/src/proxy/gateway.test.ts +108 -0
- package/src/proxy/gateway.ts +328 -0
- package/src/proxy/mcp-server-adapter.test.ts +197 -0
- package/src/proxy/mcp-server-adapter.ts +140 -0
- package/src/proxy/upstream-manager.test.ts +534 -0
- package/src/proxy/upstream-manager.ts +326 -0
- package/src/telemetry/logger.test.ts +139 -0
- package/src/telemetry/logger.ts +72 -0
- package/src/telemetry/metrics.test.ts +125 -0
- package/src/telemetry/metrics.ts +83 -0
- package/src/telemetry/tracer.ts +90 -0
- package/src/types.ts +113 -0
- package/src/wrap/proxy.test.ts +175 -0
- package/src/wrap/proxy.ts +192 -0
- 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
|
+
});
|