mock-mcp 0.3.0 → 0.3.1
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/README.md +5 -4
- package/dist/client/batch-mock-collector.d.ts +27 -3
- package/dist/client/batch-mock-collector.js +96 -11
- package/dist/client/connect.d.ts +9 -3
- package/dist/client/connect.js +16 -2
- package/dist/client/index.d.ts +1 -1
- package/dist/connect.cjs +114 -13
- package/dist/connect.d.cts +32 -45
- package/dist/index.d.ts +3 -2
- package/dist/server/test-mock-mcp-server.js +31 -4
- package/dist/types.d.ts +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,9 +70,10 @@ it("example", async () => {
|
|
|
70
70
|
instructions: "Respond with a single user described by the schema.",
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
-
fetchMock.get("/user", () =>
|
|
74
|
-
mockClient.requestMock("/user", "GET", { metadata }) // add mock via mock-mcp
|
|
75
|
-
|
|
73
|
+
fetchMock.get("/user", async () => {
|
|
74
|
+
const response = await mockClient.requestMock("/user", "GET", { metadata }) // add mock via mock-mcp
|
|
75
|
+
return response.data
|
|
76
|
+
});
|
|
76
77
|
|
|
77
78
|
const result = await fetch("/user");
|
|
78
79
|
const data = await result.json();
|
|
@@ -183,7 +184,7 @@ const mockClient = await connect({
|
|
|
183
184
|
|
|
184
185
|
await page.route("**/api/users", async (route) => {
|
|
185
186
|
const url = new URL(route.request().url());
|
|
186
|
-
const data = await mockClient.requestMock(
|
|
187
|
+
const { data } = await mockClient.requestMock(
|
|
187
188
|
url.pathname,
|
|
188
189
|
route.request().method()
|
|
189
190
|
);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ResolvedMock } from "../types.js";
|
|
1
2
|
type Logger = Pick<Console, "log" | "warn" | "error"> & {
|
|
2
3
|
debug?: (...args: unknown[]) => void;
|
|
3
4
|
};
|
|
@@ -33,6 +34,18 @@ export interface BatchMockCollectorOptions {
|
|
|
33
34
|
* Optional custom logger. Defaults to `console`.
|
|
34
35
|
*/
|
|
35
36
|
logger?: Logger;
|
|
37
|
+
/**
|
|
38
|
+
* Interval for WebSocket heartbeats in milliseconds. Set to 0 to disable.
|
|
39
|
+
*
|
|
40
|
+
* @default 15000
|
|
41
|
+
*/
|
|
42
|
+
heartbeatIntervalMs?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Automatically attempt to reconnect when the WebSocket closes unexpectedly.
|
|
45
|
+
*
|
|
46
|
+
* @default true
|
|
47
|
+
*/
|
|
48
|
+
enableReconnect?: boolean;
|
|
36
49
|
}
|
|
37
50
|
export interface RequestMockOptions {
|
|
38
51
|
body?: unknown;
|
|
@@ -44,19 +57,24 @@ export interface RequestMockOptions {
|
|
|
44
57
|
* the MCP server as a batch for AI-assisted mock generation.
|
|
45
58
|
*/
|
|
46
59
|
export declare class BatchMockCollector {
|
|
47
|
-
private
|
|
60
|
+
private ws;
|
|
48
61
|
private readonly pendingRequests;
|
|
49
62
|
private readonly queuedRequestIds;
|
|
50
63
|
private readonly timeout;
|
|
51
64
|
private readonly batchDebounceMs;
|
|
52
65
|
private readonly maxBatchSize;
|
|
53
66
|
private readonly logger;
|
|
67
|
+
private readonly heartbeatIntervalMs;
|
|
68
|
+
private readonly enableReconnect;
|
|
69
|
+
private readonly port;
|
|
54
70
|
private batchTimer;
|
|
71
|
+
private heartbeatTimer;
|
|
72
|
+
private reconnectTimer;
|
|
55
73
|
private requestIdCounter;
|
|
56
74
|
private closed;
|
|
57
75
|
private readyResolve?;
|
|
58
76
|
private readyReject?;
|
|
59
|
-
private
|
|
77
|
+
private readyPromise;
|
|
60
78
|
constructor(options?: BatchMockCollectorOptions);
|
|
61
79
|
/**
|
|
62
80
|
* Ensures the underlying WebSocket connection is ready for use.
|
|
@@ -65,7 +83,7 @@ export declare class BatchMockCollector {
|
|
|
65
83
|
/**
|
|
66
84
|
* Request mock data for a specific endpoint/method pair.
|
|
67
85
|
*/
|
|
68
|
-
requestMock<T = unknown>(endpoint: string, method: string, options?: RequestMockOptions): Promise<T
|
|
86
|
+
requestMock<T = unknown>(endpoint: string, method: string, options?: RequestMockOptions): Promise<ResolvedMock<T>>;
|
|
69
87
|
/**
|
|
70
88
|
* Wait for all requests that are currently pending to settle. Requests
|
|
71
89
|
* created after this method is called are not included.
|
|
@@ -76,11 +94,17 @@ export declare class BatchMockCollector {
|
|
|
76
94
|
*/
|
|
77
95
|
close(code?: number): Promise<void>;
|
|
78
96
|
private setupWebSocket;
|
|
97
|
+
private createWebSocket;
|
|
98
|
+
private resetReadyPromise;
|
|
99
|
+
private startHeartbeat;
|
|
100
|
+
private stopHeartbeat;
|
|
101
|
+
private scheduleReconnect;
|
|
79
102
|
private handleMessage;
|
|
80
103
|
private resolveRequest;
|
|
81
104
|
private enqueueRequest;
|
|
82
105
|
private flushQueue;
|
|
83
106
|
private sendBatch;
|
|
107
|
+
private buildResolvedMock;
|
|
84
108
|
private rejectRequest;
|
|
85
109
|
private failAllPending;
|
|
86
110
|
}
|
|
@@ -5,6 +5,7 @@ const DEFAULT_TIMEOUT = 60_000;
|
|
|
5
5
|
const DEFAULT_BATCH_DEBOUNCE_MS = 0;
|
|
6
6
|
const DEFAULT_MAX_BATCH_SIZE = 50;
|
|
7
7
|
const DEFAULT_PORT = 3002;
|
|
8
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
8
9
|
/**
|
|
9
10
|
* Collects HTTP requests issued during a single macrotask and forwards them to
|
|
10
11
|
* the MCP server as a batch for AI-assisted mock generation.
|
|
@@ -17,24 +18,28 @@ export class BatchMockCollector {
|
|
|
17
18
|
batchDebounceMs;
|
|
18
19
|
maxBatchSize;
|
|
19
20
|
logger;
|
|
21
|
+
heartbeatIntervalMs;
|
|
22
|
+
enableReconnect;
|
|
23
|
+
port;
|
|
20
24
|
batchTimer = null;
|
|
25
|
+
heartbeatTimer = null;
|
|
26
|
+
reconnectTimer = null;
|
|
21
27
|
requestIdCounter = 0;
|
|
22
28
|
closed = false;
|
|
23
29
|
readyResolve;
|
|
24
30
|
readyReject;
|
|
25
|
-
readyPromise;
|
|
31
|
+
readyPromise = Promise.resolve();
|
|
26
32
|
constructor(options = {}) {
|
|
27
33
|
this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
28
34
|
this.batchDebounceMs = options.batchDebounceMs ?? DEFAULT_BATCH_DEBOUNCE_MS;
|
|
29
35
|
this.maxBatchSize = options.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
|
|
30
36
|
this.logger = options.logger ?? console;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
this.ws = new WebSocket(wsUrl);
|
|
37
|
+
this.heartbeatIntervalMs =
|
|
38
|
+
options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
39
|
+
this.enableReconnect = options.enableReconnect ?? true;
|
|
40
|
+
this.port = options.port ?? DEFAULT_PORT;
|
|
41
|
+
this.resetReadyPromise();
|
|
42
|
+
this.ws = this.createWebSocket();
|
|
38
43
|
this.setupWebSocket();
|
|
39
44
|
}
|
|
40
45
|
/**
|
|
@@ -70,9 +75,9 @@ export class BatchMockCollector {
|
|
|
70
75
|
}, this.timeout);
|
|
71
76
|
this.pendingRequests.set(requestId, {
|
|
72
77
|
request,
|
|
73
|
-
resolve: (
|
|
78
|
+
resolve: (mock) => {
|
|
74
79
|
settleCompletion({ status: "fulfilled", value: undefined });
|
|
75
|
-
resolve(
|
|
80
|
+
resolve(this.buildResolvedMock(mock));
|
|
76
81
|
},
|
|
77
82
|
reject: (error) => {
|
|
78
83
|
settleCompletion({ status: "rejected", reason: error });
|
|
@@ -111,6 +116,14 @@ export class BatchMockCollector {
|
|
|
111
116
|
clearTimeout(this.batchTimer);
|
|
112
117
|
this.batchTimer = null;
|
|
113
118
|
}
|
|
119
|
+
if (this.heartbeatTimer) {
|
|
120
|
+
clearInterval(this.heartbeatTimer);
|
|
121
|
+
this.heartbeatTimer = null;
|
|
122
|
+
}
|
|
123
|
+
if (this.reconnectTimer) {
|
|
124
|
+
clearTimeout(this.reconnectTimer);
|
|
125
|
+
this.reconnectTimer = null;
|
|
126
|
+
}
|
|
114
127
|
this.queuedRequestIds.clear();
|
|
115
128
|
const closePromise = new Promise((resolve) => {
|
|
116
129
|
this.ws.once("close", () => resolve());
|
|
@@ -123,6 +136,7 @@ export class BatchMockCollector {
|
|
|
123
136
|
this.ws.on("open", () => {
|
|
124
137
|
this.logger.log("🔌 Connected to mock MCP WebSocket endpoint");
|
|
125
138
|
this.readyResolve?.();
|
|
139
|
+
this.startHeartbeat();
|
|
126
140
|
});
|
|
127
141
|
this.ws.on("message", (data) => this.handleMessage(data));
|
|
128
142
|
this.ws.on("error", (error) => {
|
|
@@ -132,8 +146,64 @@ export class BatchMockCollector {
|
|
|
132
146
|
});
|
|
133
147
|
this.ws.on("close", () => {
|
|
134
148
|
this.logger.warn("🔌 WebSocket connection closed");
|
|
149
|
+
this.stopHeartbeat();
|
|
135
150
|
this.failAllPending(new Error("WebSocket connection closed"));
|
|
151
|
+
if (!this.closed && this.enableReconnect) {
|
|
152
|
+
this.scheduleReconnect();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
createWebSocket() {
|
|
157
|
+
const wsUrl = `ws://localhost:${this.port}`;
|
|
158
|
+
return new WebSocket(wsUrl);
|
|
159
|
+
}
|
|
160
|
+
resetReadyPromise() {
|
|
161
|
+
this.readyPromise = new Promise((resolve, reject) => {
|
|
162
|
+
this.readyResolve = resolve;
|
|
163
|
+
this.readyReject = reject;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
startHeartbeat() {
|
|
167
|
+
if (this.heartbeatIntervalMs <= 0 || this.heartbeatTimer) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
let lastPong = Date.now();
|
|
171
|
+
this.ws.on("pong", () => {
|
|
172
|
+
lastPong = Date.now();
|
|
136
173
|
});
|
|
174
|
+
this.heartbeatTimer = setInterval(() => {
|
|
175
|
+
if (this.ws.readyState !== WebSocket.OPEN) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
if (now - lastPong > this.heartbeatIntervalMs * 2) {
|
|
180
|
+
this.logger.warn("Heartbeat missed; closing socket to trigger reconnect...");
|
|
181
|
+
this.ws.close();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.ws.ping();
|
|
185
|
+
}, this.heartbeatIntervalMs);
|
|
186
|
+
this.heartbeatTimer.unref?.();
|
|
187
|
+
}
|
|
188
|
+
stopHeartbeat() {
|
|
189
|
+
if (this.heartbeatTimer) {
|
|
190
|
+
clearInterval(this.heartbeatTimer);
|
|
191
|
+
this.heartbeatTimer = null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
scheduleReconnect() {
|
|
195
|
+
if (this.reconnectTimer || this.closed) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
this.reconnectTimer = setTimeout(() => {
|
|
199
|
+
this.reconnectTimer = null;
|
|
200
|
+
this.logger.warn("🔄 Reconnecting to mock MCP WebSocket endpoint...");
|
|
201
|
+
this.stopHeartbeat();
|
|
202
|
+
this.resetReadyPromise();
|
|
203
|
+
this.ws = this.createWebSocket();
|
|
204
|
+
this.setupWebSocket();
|
|
205
|
+
}, 1_000);
|
|
206
|
+
this.reconnectTimer.unref?.();
|
|
137
207
|
}
|
|
138
208
|
handleMessage(data) {
|
|
139
209
|
let parsed;
|
|
@@ -161,7 +231,13 @@ export class BatchMockCollector {
|
|
|
161
231
|
}
|
|
162
232
|
clearTimeout(pending.timeoutId);
|
|
163
233
|
this.pendingRequests.delete(mock.requestId);
|
|
164
|
-
pending.resolve(mock
|
|
234
|
+
const resolve = () => pending.resolve(mock);
|
|
235
|
+
if (mock.delayMs && mock.delayMs > 0) {
|
|
236
|
+
setTimeout(resolve, mock.delayMs);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
resolve();
|
|
240
|
+
}
|
|
165
241
|
}
|
|
166
242
|
enqueueRequest(requestId) {
|
|
167
243
|
this.queuedRequestIds.add(requestId);
|
|
@@ -206,6 +282,15 @@ export class BatchMockCollector {
|
|
|
206
282
|
this.logger.debug?.(`📤 Sending batch with ${requests.length} request(s) to MCP server`);
|
|
207
283
|
this.ws.send(JSON.stringify(payload));
|
|
208
284
|
}
|
|
285
|
+
buildResolvedMock(mock) {
|
|
286
|
+
return {
|
|
287
|
+
requestId: mock.requestId,
|
|
288
|
+
data: mock.data,
|
|
289
|
+
status: mock.status,
|
|
290
|
+
headers: mock.headers,
|
|
291
|
+
delayMs: mock.delayMs,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
209
294
|
rejectRequest(requestId, error) {
|
|
210
295
|
const pending = this.pendingRequests.get(requestId);
|
|
211
296
|
if (!pending) {
|
package/dist/client/connect.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
1
|
+
import type { BatchMockCollectorOptions, RequestMockOptions } from "./batch-mock-collector.js";
|
|
2
|
+
import type { ResolvedMock } from "../types.js";
|
|
3
3
|
export type ConnectOptions = number | BatchMockCollectorOptions | undefined;
|
|
4
|
+
export interface MockClient {
|
|
5
|
+
waitUntilReady(): Promise<void>;
|
|
6
|
+
requestMock<T = unknown>(endpoint: string, method: string, options?: RequestMockOptions): Promise<ResolvedMock<T>>;
|
|
7
|
+
waitForPendingRequests(): Promise<void>;
|
|
8
|
+
close(code?: number): Promise<void>;
|
|
9
|
+
}
|
|
4
10
|
/**
|
|
5
11
|
* Convenience helper that creates a {@link BatchMockCollector} and waits for the
|
|
6
12
|
* underlying WebSocket connection to become ready before resolving.
|
|
7
13
|
*/
|
|
8
|
-
export declare const connect: (options?: ConnectOptions) => Promise<
|
|
14
|
+
export declare const connect: (options?: ConnectOptions) => Promise<MockClient>;
|
package/dist/client/connect.js
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
import { BatchMockCollector } from "./batch-mock-collector.js";
|
|
2
2
|
import { isEnabled } from "./util.js";
|
|
3
|
+
class DisabledMockClient {
|
|
4
|
+
async waitUntilReady() {
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
async requestMock() {
|
|
8
|
+
throw new Error("[mock-mcp] MOCK_MCP is not enabled. Set MOCK_MCP=1 to enable mock generation.");
|
|
9
|
+
}
|
|
10
|
+
async waitForPendingRequests() {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
async close() {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
3
17
|
/**
|
|
4
18
|
* Convenience helper that creates a {@link BatchMockCollector} and waits for the
|
|
5
19
|
* underlying WebSocket connection to become ready before resolving.
|
|
6
20
|
*/
|
|
7
21
|
export const connect = async (options) => {
|
|
22
|
+
const resolvedOptions = typeof options === "number" ? { port: options } : options ?? {};
|
|
8
23
|
if (!isEnabled()) {
|
|
9
24
|
console.log("[mock-mcp] Skipping (set MOCK_MCP=1 to enable)");
|
|
10
|
-
return;
|
|
25
|
+
return new DisabledMockClient();
|
|
11
26
|
}
|
|
12
|
-
const resolvedOptions = typeof options === "number" ? { port: options } : options ?? {};
|
|
13
27
|
const collector = new BatchMockCollector(resolvedOptions);
|
|
14
28
|
await collector.waitUntilReady();
|
|
15
29
|
return collector;
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { BatchMockCollector, type BatchMockCollectorOptions, type RequestMockOptions, } from "./batch-mock-collector.js";
|
|
2
|
-
export { connect, type ConnectOptions } from "./connect.js";
|
|
2
|
+
export { connect, type ConnectOptions, type MockClient } from "./connect.js";
|
package/dist/connect.cjs
CHANGED
|
@@ -51,6 +51,7 @@ var DEFAULT_TIMEOUT = 6e4;
|
|
|
51
51
|
var DEFAULT_BATCH_DEBOUNCE_MS = 0;
|
|
52
52
|
var DEFAULT_MAX_BATCH_SIZE = 50;
|
|
53
53
|
var DEFAULT_PORT = 3002;
|
|
54
|
+
var DEFAULT_HEARTBEAT_INTERVAL_MS = 15e3;
|
|
54
55
|
var BatchMockCollector = class {
|
|
55
56
|
ws;
|
|
56
57
|
pendingRequests = /* @__PURE__ */ new Map();
|
|
@@ -59,24 +60,27 @@ var BatchMockCollector = class {
|
|
|
59
60
|
batchDebounceMs;
|
|
60
61
|
maxBatchSize;
|
|
61
62
|
logger;
|
|
63
|
+
heartbeatIntervalMs;
|
|
64
|
+
enableReconnect;
|
|
65
|
+
port;
|
|
62
66
|
batchTimer = null;
|
|
67
|
+
heartbeatTimer = null;
|
|
68
|
+
reconnectTimer = null;
|
|
63
69
|
requestIdCounter = 0;
|
|
64
70
|
closed = false;
|
|
65
71
|
readyResolve;
|
|
66
72
|
readyReject;
|
|
67
|
-
readyPromise;
|
|
73
|
+
readyPromise = Promise.resolve();
|
|
68
74
|
constructor(options = {}) {
|
|
69
75
|
this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
70
76
|
this.batchDebounceMs = options.batchDebounceMs ?? DEFAULT_BATCH_DEBOUNCE_MS;
|
|
71
77
|
this.maxBatchSize = options.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
|
|
72
78
|
this.logger = options.logger ?? console;
|
|
73
|
-
|
|
74
|
-
this.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const wsUrl = `ws://localhost:${port}`;
|
|
79
|
-
this.ws = new import_ws.default(wsUrl);
|
|
79
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
80
|
+
this.enableReconnect = options.enableReconnect ?? true;
|
|
81
|
+
this.port = options.port ?? DEFAULT_PORT;
|
|
82
|
+
this.resetReadyPromise();
|
|
83
|
+
this.ws = this.createWebSocket();
|
|
80
84
|
this.setupWebSocket();
|
|
81
85
|
}
|
|
82
86
|
/**
|
|
@@ -117,9 +121,9 @@ var BatchMockCollector = class {
|
|
|
117
121
|
}, this.timeout);
|
|
118
122
|
this.pendingRequests.set(requestId, {
|
|
119
123
|
request,
|
|
120
|
-
resolve: (
|
|
124
|
+
resolve: (mock) => {
|
|
121
125
|
settleCompletion({ status: "fulfilled", value: void 0 });
|
|
122
|
-
resolve(
|
|
126
|
+
resolve(this.buildResolvedMock(mock));
|
|
123
127
|
},
|
|
124
128
|
reject: (error) => {
|
|
125
129
|
settleCompletion({ status: "rejected", reason: error });
|
|
@@ -162,6 +166,14 @@ var BatchMockCollector = class {
|
|
|
162
166
|
clearTimeout(this.batchTimer);
|
|
163
167
|
this.batchTimer = null;
|
|
164
168
|
}
|
|
169
|
+
if (this.heartbeatTimer) {
|
|
170
|
+
clearInterval(this.heartbeatTimer);
|
|
171
|
+
this.heartbeatTimer = null;
|
|
172
|
+
}
|
|
173
|
+
if (this.reconnectTimer) {
|
|
174
|
+
clearTimeout(this.reconnectTimer);
|
|
175
|
+
this.reconnectTimer = null;
|
|
176
|
+
}
|
|
165
177
|
this.queuedRequestIds.clear();
|
|
166
178
|
const closePromise = new Promise((resolve) => {
|
|
167
179
|
this.ws.once("close", () => resolve());
|
|
@@ -174,6 +186,7 @@ var BatchMockCollector = class {
|
|
|
174
186
|
this.ws.on("open", () => {
|
|
175
187
|
this.logger.log("\u{1F50C} Connected to mock MCP WebSocket endpoint");
|
|
176
188
|
this.readyResolve?.();
|
|
189
|
+
this.startHeartbeat();
|
|
177
190
|
});
|
|
178
191
|
this.ws.on("message", (data) => this.handleMessage(data));
|
|
179
192
|
this.ws.on("error", (error) => {
|
|
@@ -187,9 +200,67 @@ var BatchMockCollector = class {
|
|
|
187
200
|
});
|
|
188
201
|
this.ws.on("close", () => {
|
|
189
202
|
this.logger.warn("\u{1F50C} WebSocket connection closed");
|
|
203
|
+
this.stopHeartbeat();
|
|
190
204
|
this.failAllPending(new Error("WebSocket connection closed"));
|
|
205
|
+
if (!this.closed && this.enableReconnect) {
|
|
206
|
+
this.scheduleReconnect();
|
|
207
|
+
}
|
|
191
208
|
});
|
|
192
209
|
}
|
|
210
|
+
createWebSocket() {
|
|
211
|
+
const wsUrl = `ws://localhost:${this.port}`;
|
|
212
|
+
return new import_ws.default(wsUrl);
|
|
213
|
+
}
|
|
214
|
+
resetReadyPromise() {
|
|
215
|
+
this.readyPromise = new Promise((resolve, reject) => {
|
|
216
|
+
this.readyResolve = resolve;
|
|
217
|
+
this.readyReject = reject;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
startHeartbeat() {
|
|
221
|
+
if (this.heartbeatIntervalMs <= 0 || this.heartbeatTimer) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
let lastPong = Date.now();
|
|
225
|
+
this.ws.on("pong", () => {
|
|
226
|
+
lastPong = Date.now();
|
|
227
|
+
});
|
|
228
|
+
this.heartbeatTimer = setInterval(() => {
|
|
229
|
+
if (this.ws.readyState !== import_ws.default.OPEN) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
if (now - lastPong > this.heartbeatIntervalMs * 2) {
|
|
234
|
+
this.logger.warn(
|
|
235
|
+
"Heartbeat missed; closing socket to trigger reconnect..."
|
|
236
|
+
);
|
|
237
|
+
this.ws.close();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
this.ws.ping();
|
|
241
|
+
}, this.heartbeatIntervalMs);
|
|
242
|
+
this.heartbeatTimer.unref?.();
|
|
243
|
+
}
|
|
244
|
+
stopHeartbeat() {
|
|
245
|
+
if (this.heartbeatTimer) {
|
|
246
|
+
clearInterval(this.heartbeatTimer);
|
|
247
|
+
this.heartbeatTimer = null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
scheduleReconnect() {
|
|
251
|
+
if (this.reconnectTimer || this.closed) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.reconnectTimer = setTimeout(() => {
|
|
255
|
+
this.reconnectTimer = null;
|
|
256
|
+
this.logger.warn("\u{1F504} Reconnecting to mock MCP WebSocket endpoint...");
|
|
257
|
+
this.stopHeartbeat();
|
|
258
|
+
this.resetReadyPromise();
|
|
259
|
+
this.ws = this.createWebSocket();
|
|
260
|
+
this.setupWebSocket();
|
|
261
|
+
}, 1e3);
|
|
262
|
+
this.reconnectTimer.unref?.();
|
|
263
|
+
}
|
|
193
264
|
handleMessage(data) {
|
|
194
265
|
let parsed;
|
|
195
266
|
try {
|
|
@@ -217,7 +288,12 @@ var BatchMockCollector = class {
|
|
|
217
288
|
}
|
|
218
289
|
clearTimeout(pending.timeoutId);
|
|
219
290
|
this.pendingRequests.delete(mock.requestId);
|
|
220
|
-
pending.resolve(mock
|
|
291
|
+
const resolve = () => pending.resolve(mock);
|
|
292
|
+
if (mock.delayMs && mock.delayMs > 0) {
|
|
293
|
+
setTimeout(resolve, mock.delayMs);
|
|
294
|
+
} else {
|
|
295
|
+
resolve();
|
|
296
|
+
}
|
|
221
297
|
}
|
|
222
298
|
enqueueRequest(requestId) {
|
|
223
299
|
this.queuedRequestIds.add(requestId);
|
|
@@ -266,6 +342,15 @@ var BatchMockCollector = class {
|
|
|
266
342
|
);
|
|
267
343
|
this.ws.send(JSON.stringify(payload));
|
|
268
344
|
}
|
|
345
|
+
buildResolvedMock(mock) {
|
|
346
|
+
return {
|
|
347
|
+
requestId: mock.requestId,
|
|
348
|
+
data: mock.data,
|
|
349
|
+
status: mock.status,
|
|
350
|
+
headers: mock.headers,
|
|
351
|
+
delayMs: mock.delayMs
|
|
352
|
+
};
|
|
353
|
+
}
|
|
269
354
|
rejectRequest(requestId, error) {
|
|
270
355
|
const pending = this.pendingRequests.get(requestId);
|
|
271
356
|
if (!pending) {
|
|
@@ -283,12 +368,28 @@ var BatchMockCollector = class {
|
|
|
283
368
|
};
|
|
284
369
|
|
|
285
370
|
// src/client/connect.ts
|
|
371
|
+
var DisabledMockClient = class {
|
|
372
|
+
async waitUntilReady() {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
async requestMock() {
|
|
376
|
+
throw new Error(
|
|
377
|
+
"[mock-mcp] MOCK_MCP is not enabled. Set MOCK_MCP=1 to enable mock generation."
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
async waitForPendingRequests() {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
async close() {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
};
|
|
286
387
|
var connect = async (options) => {
|
|
388
|
+
const resolvedOptions = typeof options === "number" ? { port: options } : options ?? {};
|
|
287
389
|
if (!isEnabled()) {
|
|
288
390
|
console.log("[mock-mcp] Skipping (set MOCK_MCP=1 to enable)");
|
|
289
|
-
return;
|
|
391
|
+
return new DisabledMockClient();
|
|
290
392
|
}
|
|
291
|
-
const resolvedOptions = typeof options === "number" ? { port: options } : options ?? {};
|
|
292
393
|
const collector = new BatchMockCollector(resolvedOptions);
|
|
293
394
|
await collector.waitUntilReady();
|
|
294
395
|
return collector;
|
package/dist/connect.d.cts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape of the mock data that needs to be returned for a request.
|
|
3
|
+
*/
|
|
4
|
+
interface MockResponseDescriptor {
|
|
5
|
+
requestId: string;
|
|
6
|
+
data: unknown;
|
|
7
|
+
status?: number;
|
|
8
|
+
headers?: Record<string, string>;
|
|
9
|
+
delayMs?: number;
|
|
10
|
+
}
|
|
11
|
+
interface ResolvedMock<T = unknown> extends Omit<MockResponseDescriptor, "data"> {
|
|
12
|
+
data: T;
|
|
13
|
+
}
|
|
14
|
+
|
|
1
15
|
type Logger = Pick<Console, "log" | "warn" | "error"> & {
|
|
2
16
|
debug?: (...args: unknown[]) => void;
|
|
3
17
|
};
|
|
@@ -33,63 +47,36 @@ interface BatchMockCollectorOptions {
|
|
|
33
47
|
* Optional custom logger. Defaults to `console`.
|
|
34
48
|
*/
|
|
35
49
|
logger?: Logger;
|
|
50
|
+
/**
|
|
51
|
+
* Interval for WebSocket heartbeats in milliseconds. Set to 0 to disable.
|
|
52
|
+
*
|
|
53
|
+
* @default 15000
|
|
54
|
+
*/
|
|
55
|
+
heartbeatIntervalMs?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Automatically attempt to reconnect when the WebSocket closes unexpectedly.
|
|
58
|
+
*
|
|
59
|
+
* @default true
|
|
60
|
+
*/
|
|
61
|
+
enableReconnect?: boolean;
|
|
36
62
|
}
|
|
37
63
|
interface RequestMockOptions {
|
|
38
64
|
body?: unknown;
|
|
39
65
|
headers?: Record<string, string>;
|
|
40
66
|
metadata?: Record<string, unknown>;
|
|
41
67
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*/
|
|
46
|
-
declare class BatchMockCollector {
|
|
47
|
-
private readonly ws;
|
|
48
|
-
private readonly pendingRequests;
|
|
49
|
-
private readonly queuedRequestIds;
|
|
50
|
-
private readonly timeout;
|
|
51
|
-
private readonly batchDebounceMs;
|
|
52
|
-
private readonly maxBatchSize;
|
|
53
|
-
private readonly logger;
|
|
54
|
-
private batchTimer;
|
|
55
|
-
private requestIdCounter;
|
|
56
|
-
private closed;
|
|
57
|
-
private readyResolve?;
|
|
58
|
-
private readyReject?;
|
|
59
|
-
private readonly readyPromise;
|
|
60
|
-
constructor(options?: BatchMockCollectorOptions);
|
|
61
|
-
/**
|
|
62
|
-
* Ensures the underlying WebSocket connection is ready for use.
|
|
63
|
-
*/
|
|
68
|
+
|
|
69
|
+
type ConnectOptions = number | BatchMockCollectorOptions | undefined;
|
|
70
|
+
interface MockClient {
|
|
64
71
|
waitUntilReady(): Promise<void>;
|
|
65
|
-
|
|
66
|
-
* Request mock data for a specific endpoint/method pair.
|
|
67
|
-
*/
|
|
68
|
-
requestMock<T = unknown>(endpoint: string, method: string, options?: RequestMockOptions): Promise<T>;
|
|
69
|
-
/**
|
|
70
|
-
* Wait for all requests that are currently pending to settle. Requests
|
|
71
|
-
* created after this method is called are not included.
|
|
72
|
-
*/
|
|
72
|
+
requestMock<T = unknown>(endpoint: string, method: string, options?: RequestMockOptions): Promise<ResolvedMock<T>>;
|
|
73
73
|
waitForPendingRequests(): Promise<void>;
|
|
74
|
-
/**
|
|
75
|
-
* Close the underlying connection and fail all pending requests.
|
|
76
|
-
*/
|
|
77
74
|
close(code?: number): Promise<void>;
|
|
78
|
-
private setupWebSocket;
|
|
79
|
-
private handleMessage;
|
|
80
|
-
private resolveRequest;
|
|
81
|
-
private enqueueRequest;
|
|
82
|
-
private flushQueue;
|
|
83
|
-
private sendBatch;
|
|
84
|
-
private rejectRequest;
|
|
85
|
-
private failAllPending;
|
|
86
75
|
}
|
|
87
|
-
|
|
88
|
-
type ConnectOptions = number | BatchMockCollectorOptions | undefined;
|
|
89
76
|
/**
|
|
90
77
|
* Convenience helper that creates a {@link BatchMockCollector} and waits for the
|
|
91
78
|
* underlying WebSocket connection to become ready before resolving.
|
|
92
79
|
*/
|
|
93
|
-
declare const connect: (options?: ConnectOptions) => Promise<
|
|
80
|
+
declare const connect: (options?: ConnectOptions) => Promise<MockClient>;
|
|
94
81
|
|
|
95
|
-
export { type ConnectOptions, connect };
|
|
82
|
+
export { type ConnectOptions, type MockClient, connect };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { TestMockMCPServer, type TestMockMCPServerOptions } from "./server/test-mock-mcp-server.js";
|
|
3
3
|
import { BatchMockCollector, type BatchMockCollectorOptions, type RequestMockOptions } from "./client/batch-mock-collector.js";
|
|
4
|
-
import { connect, type ConnectOptions } from "./client/connect.js";
|
|
4
|
+
import { connect, type ConnectOptions, type MockClient } from "./client/connect.js";
|
|
5
|
+
import type { ResolvedMock } from "./types.js";
|
|
5
6
|
export { TestMockMCPServer };
|
|
6
7
|
export type { TestMockMCPServerOptions };
|
|
7
8
|
export { BatchMockCollector };
|
|
8
9
|
export type { BatchMockCollectorOptions, RequestMockOptions };
|
|
9
10
|
export { connect };
|
|
10
|
-
export type { ConnectOptions };
|
|
11
|
+
export type { ConnectOptions, MockClient, ResolvedMock };
|
|
@@ -109,9 +109,21 @@ export class TestMockMCPServer {
|
|
|
109
109
|
if (!batch) {
|
|
110
110
|
throw new Error(`Batch not found: ${batchId}`);
|
|
111
111
|
}
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
const expectedIds = new Set(batch.requests.map((request) => request.requestId));
|
|
113
|
+
const providedIds = new Set();
|
|
114
|
+
const unknownMock = mocks.find((mock) => !expectedIds.has(mock.requestId));
|
|
115
|
+
if (unknownMock) {
|
|
116
|
+
throw new Error(`Mock data references unknown requestId: ${unknownMock.requestId}`);
|
|
117
|
+
}
|
|
118
|
+
for (const mock of mocks) {
|
|
119
|
+
if (providedIds.has(mock.requestId)) {
|
|
120
|
+
throw new Error(`Duplicate mock data provided for requestId: ${mock.requestId}`);
|
|
121
|
+
}
|
|
122
|
+
providedIds.add(mock.requestId);
|
|
123
|
+
}
|
|
124
|
+
const missingIds = Array.from(expectedIds).filter((requestId) => !providedIds.has(requestId));
|
|
125
|
+
if (missingIds.length > 0) {
|
|
126
|
+
throw new Error(`Missing mock data for requestId(s): ${missingIds.join(", ")}`);
|
|
115
127
|
}
|
|
116
128
|
if (batch.ws.readyState !== WebSocket.OPEN) {
|
|
117
129
|
this.pendingBatches.delete(batchId);
|
|
@@ -204,7 +216,14 @@ export class TestMockMCPServer {
|
|
|
204
216
|
properties: {
|
|
205
217
|
requestId: { type: "string" },
|
|
206
218
|
data: {
|
|
207
|
-
|
|
219
|
+
anyOf: [
|
|
220
|
+
{ type: "object" },
|
|
221
|
+
{ type: "array" },
|
|
222
|
+
{ type: "string" },
|
|
223
|
+
{ type: "number" },
|
|
224
|
+
{ type: "boolean" },
|
|
225
|
+
{ type: "null" },
|
|
226
|
+
],
|
|
208
227
|
},
|
|
209
228
|
status: {
|
|
210
229
|
type: "number",
|
|
@@ -249,6 +268,14 @@ export class TestMockMCPServer {
|
|
|
249
268
|
this.logger.error("🔌 Test process connected");
|
|
250
269
|
this.clients.add(ws);
|
|
251
270
|
ws.on("message", (data) => this.handleClientMessage(ws, data));
|
|
271
|
+
ws.on("ping", () => {
|
|
272
|
+
try {
|
|
273
|
+
ws.pong();
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
this.logger.warn("Failed to respond to ping:", error);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
252
279
|
ws.on("close", () => {
|
|
253
280
|
this.logger.error("🔌 Test process disconnected");
|
|
254
281
|
this.clients.delete(ws);
|
package/dist/types.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ export interface MockResponseDescriptor {
|
|
|
21
21
|
headers?: Record<string, string>;
|
|
22
22
|
delayMs?: number;
|
|
23
23
|
}
|
|
24
|
+
export interface ResolvedMock<T = unknown> extends Omit<MockResponseDescriptor, "data"> {
|
|
25
|
+
data: T;
|
|
26
|
+
}
|
|
24
27
|
export interface BatchMockRequestMessage {
|
|
25
28
|
type: typeof BATCH_MOCK_REQUEST;
|
|
26
29
|
requests: MockRequestDescriptor[];
|