-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmcp-client.test.ts
More file actions
151 lines (126 loc) · 6.28 KB
/
Copy pathmcp-client.test.ts
File metadata and controls
151 lines (126 loc) · 6.28 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
connect: vi.fn(),
request: vi.fn(),
close: vi.fn(),
transport: vi.fn(),
existsSync: vi.fn(),
}));
vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: class {
connect = mocks.connect;
request = mocks.request;
close = mocks.close;
},
}));
vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({
StdioClientTransport: class {
constructor(options: unknown) {
mocks.transport(options);
}
},
}));
vi.mock("fs", () => ({ existsSync: mocks.existsSync }));
import { callMcpTool, resolveServerPath } from "../hooks/lib/mcp-client.js";
describe("hook MCP client", () => {
beforeEach(() => {
mocks.close.mockReset().mockResolvedValue(undefined);
mocks.connect.mockReset().mockResolvedValue(undefined);
mocks.request.mockReset();
mocks.transport.mockReset();
mocks.existsSync.mockReset().mockReturnValue(false);
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
delete process.env.PROMPTIMPROVER_SERVER_PATH;
delete process.env.PROMPTIMPROVER_HOOK_TIMEOUT_MS;
});
it("calls a tool, returns text, honors timeout, and closes the client", async () => {
process.env.PROMPTIMPROVER_SERVER_PATH = "./custom-server.js";
process.env.PROMPTIMPROVER_HOOK_TIMEOUT_MS = "25";
mocks.request.mockResolvedValue({ content: [{ type: "text", text: "result" }] });
await expect(callMcpTool("lint_prompt", { prompt: "test" })).resolves.toBe("result");
expect(mocks.transport).toHaveBeenCalledWith(expect.objectContaining({ args: [resolveServerPath()] }));
const requestOptions = mocks.request.mock.calls[0][2] as { timeout: number; maxTotalTimeout: number };
expect(requestOptions.timeout).toBeGreaterThan(0);
expect(requestOptions.timeout).toBeLessThanOrEqual(25);
expect(requestOptions.maxTotalTimeout).toBe(requestOptions.timeout);
expect(mocks.close).toHaveBeenCalledOnce();
});
it("throws for missing text and still closes after request failures", async () => {
mocks.request.mockResolvedValueOnce({ content: [] }).mockRejectedValueOnce(new Error("closed"));
await expect(callMcpTool("lint_prompt", {})).rejects.toThrow(/no text/);
await expect(callMcpTool("lint_prompt", {})).rejects.toThrow("closed");
expect(mocks.close).toHaveBeenCalledTimes(2);
});
it("retries one reconnect-safe transport failure with a fresh client", async () => {
mocks.request
.mockRejectedValueOnce(Object.assign(new Error("private transport detail"), { code: "ECONNRESET" }))
.mockResolvedValueOnce({ content: [{ type: "text", text: "recovered" }] });
await expect(callMcpTool("lint_prompt", {})).resolves.toBe("recovered");
expect(mocks.connect).toHaveBeenCalledTimes(2);
expect(mocks.request).toHaveBeenCalledTimes(2);
expect(mocks.close).toHaveBeenCalledTimes(2);
});
it("does not retry non-transport failures or more than once", async () => {
mocks.request
.mockRejectedValueOnce(Object.assign(new Error("closed"), { code: -32000 }))
.mockRejectedValueOnce(Object.assign(new Error("closed again"), { code: -32000 }));
await expect(callMcpTool("lint_prompt", {})).rejects.toThrow("closed again");
expect(mocks.request).toHaveBeenCalledTimes(2);
mocks.request.mockReset().mockRejectedValueOnce(new Error("tool failed"));
await expect(callMcpTool("lint_prompt", {})).rejects.toThrow("tool failed");
expect(mocks.request).toHaveBeenCalledOnce();
mocks.request.mockReset().mockRejectedValueOnce("non-error failure");
await expect(callMcpTool("lint_prompt", {})).rejects.toBe("non-error failure");
});
it("bounds the total connect and request duration", async () => {
vi.useFakeTimers();
process.env.PROMPTIMPROVER_HOOK_TIMEOUT_MS = "25";
mocks.connect.mockImplementation(() => new Promise(() => undefined));
const result = callMcpTool("lint_prompt", {});
const assertion = expect(result).rejects.toMatchObject({ code: -32001 });
await vi.advanceTimersByTimeAsync(25);
await assertion;
expect(mocks.request).not.toHaveBeenCalled();
expect(mocks.close).toHaveBeenCalledOnce();
vi.useRealTimers();
});
it("shares one deadline across connection and request", async () => {
vi.useFakeTimers();
process.env.PROMPTIMPROVER_HOOK_TIMEOUT_MS = "25";
mocks.connect.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 15)));
mocks.request.mockImplementation(() => new Promise(() => undefined));
const result = callMcpTool("lint_prompt", {});
const assertion = expect(result).rejects.toMatchObject({ code: -32001 });
await vi.advanceTimersByTimeAsync(15);
expect(mocks.request).toHaveBeenCalledOnce();
expect(mocks.request.mock.calls[0][2]).toEqual({ timeout: 10, maxTotalTimeout: 10 });
await vi.advanceTimersByTimeAsync(10);
await assertion;
expect(mocks.close).toHaveBeenCalledOnce();
vi.useRealTimers();
});
it("resolves built server candidates and uses the default timeout", async () => {
mocks.request.mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
expect(resolveServerPath()).toMatch(/src[\\/]index\.js$/);
await callMcpTool("lint_prompt", {});
const options = mocks.request.mock.calls[0][2] as { timeout: number; maxTotalTimeout: number };
expect(options.timeout).toBeGreaterThan(0);
expect(options.timeout).toBeLessThanOrEqual(15_000);
expect(options.maxTotalTimeout).toBe(options.timeout);
});
it("selects an existing built candidate, rejects invalid timeouts, and tolerates close failures", async () => {
mocks.existsSync.mockImplementation((candidate: string) => candidate.includes("dist"));
mocks.request.mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
mocks.close.mockRejectedValue(new Error("close failed"));
process.env.PROMPTIMPROVER_HOOK_TIMEOUT_MS = "-1";
expect(resolveServerPath()).toMatch(/dist[\\/]src[\\/]index\.js$/);
await expect(callMcpTool("lint_prompt", {})).resolves.toBe("ok");
const options = mocks.request.mock.calls[0][2] as { timeout: number; maxTotalTimeout: number };
expect(options.timeout).toBeGreaterThan(0);
expect(options.timeout).toBeLessThanOrEqual(15_000);
expect(options.maxTotalTimeout).toBe(options.timeout);
});
});