mock-mcp 0.2.3 → 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 CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm](https://img.shields.io/npm/v/mock-mcp.svg)](https://www.npmjs.com/package/mock-mcp)
5
5
  ![license](https://img.shields.io/npm/l/mock-mcp)
6
6
 
7
- Mock MCP Server - AI-generated mock data. The project pairs a WebSocket batch bridge with MCP tooling so Cursor, Claude Desktop, or any compatible client can fulfill intercepted requests in real time.
7
+ Mock MCP Server - AI-generated mock data based on your **OpenAPI JSON Schema** definitions. The project pairs a WebSocket batch bridge with MCP tooling so Cursor, Claude Desktop, or any compatible client can fulfill intercepted requests in real time, ensuring strict contract compliance.
8
8
 
9
9
  ## Table of Contents
10
10
 
@@ -25,62 +25,67 @@ Mock MCP Server - AI-generated mock data. The project pairs a WebSocket batch br
25
25
 
26
26
  1. **Install the package.** Add mock-mcp as a dev dependency inside your project.
27
27
 
28
- ```bash
29
- npm install -D mock-mcp
30
- ```
28
+ ```bash
29
+ npm install -D mock-mcp
30
+ # or
31
+ yarn add -D mock-mcp
32
+ # or
33
+ pnpm add -D mock-mcp
34
+ ```
31
35
 
32
36
  2. **Configure the Model Context Protocol server.** For example, Claude Desktop can launch the binary through npx:
33
37
 
34
- ```json
35
- {
36
- "mock-mcp": {
37
- "command": "npx",
38
- "args": ["-y", "mock-mcp@latest"]
39
- }
40
- }
41
- ```
38
+ ```json
39
+ {
40
+ "mock-mcp": {
41
+ "command": "npx",
42
+ "args": ["-y", "mock-mcp@latest"]
43
+ }
44
+ }
45
+ ```
42
46
 
43
47
  3. **Connect from your tests.** Use `connect` to retrieve a mock client and request data for intercepted calls.
44
48
 
45
- ```ts
46
- import { render, screen, fireEvent } from "@testing-library/react";
47
- import { connect } from "mock-mcp";
48
-
49
- const userSchema = {
50
- summary: "Fetch the current user",
51
- response: {
52
- type: "object",
53
- required: ["id", "name"],
54
- properties: {
55
- id: { type: "number" },
56
- name: { type: "string" },
57
- },
58
- },
59
- };
60
-
61
- it("example", async () => {
62
- const mockClient = await connect();
63
- const metadata = {
64
- schemaUrl: "https://example.com/openapi.json#/paths/~1user/get",
65
- schema: userSchema,
66
- instructions: "Respond with a single user described by the schema.",
67
- };
68
-
69
- fetchMock.get("/user", () =>
70
- mockClient.requestMock("/user", "GET", { metadata })
71
- );
72
-
73
- const result = await fetch("/user");
74
- const data = await result.json();
75
- expect(data).toEqual({ id: 1, name: "Jane" });
76
- }); // 10 minute timeout for AI interaction
77
- ```
49
+ ```ts
50
+ import { render, screen, fireEvent } from "@testing-library/react";
51
+ import { connect } from "mock-mcp";
52
+
53
+ const userSchema = {
54
+ summary: "Fetch the current user",
55
+ response: {
56
+ type: "object",
57
+ required: ["id", "name"],
58
+ properties: {
59
+ id: { type: "number" },
60
+ name: { type: "string" },
61
+ },
62
+ },
63
+ };
64
+
65
+ it("example", async () => {
66
+ const mockClient = await connect();
67
+ const metadata = {
68
+ schemaUrl: "https://example.com/openapi.json#/paths/~1user/get",
69
+ schema: userSchema,
70
+ instructions: "Respond with a single user described by the schema.",
71
+ };
72
+
73
+ fetchMock.get("/user", async () => {
74
+ const response = await mockClient.requestMock("/user", "GET", { metadata }) // add mock via mock-mcp
75
+ return response.data
76
+ });
77
+
78
+ const result = await fetch("/user");
79
+ const data = await result.json();
80
+ expect(data).toEqual({ id: 1, name: "Jane" });
81
+ }, 10 * 60 * 1000); // 10 minute timeout for AI interaction
82
+ ```
78
83
 
79
84
  4. **Run with MCP enabled.** Prompt your AI client to run the persistent test command and provide mocks through the tools.
80
85
 
81
- ```
82
- Please run the persistent test: `MOCK_MCP=true npm test test/example.test.tsx` and mock fetch data with mock-mcp
83
- ```
86
+ ```
87
+ Please run the persistent test: `MOCK_MCP=true npm test test/example.test.tsx` and mock fetch data with mock-mcp
88
+ ```
84
89
 
85
90
  ## Why Mock MCP
86
91
 
@@ -116,15 +121,20 @@ Traditional: Write Test → Create Fixtures → Run Test → Maintain Fixtures
116
121
  ↑ ↓
117
122
  └──────── Pain Loop ───────┘
118
123
 
119
- Mock MCP: Write Test → AI Generates Data → Run Test → Solidify Code
120
-
121
- └─────── Evolution ─────────┘
124
+ Mock MCP: Write Test → AI Generates Data (Schema-Compliant) → Run Test → Solidify Code
125
+
126
+ └───────────── Evolution ────────────┘
122
127
  ```
123
128
 
129
+ ### Schema-Driven Accuracy
130
+
131
+ Unlike "hallucinated" mocks, Mock MCP uses your actual **OpenAPI JSON Schema** definitions to ground the AI. This ensures that generated data not only looks real but strictly adheres to your API contracts, catching integration issues early.
132
+
124
133
  ## What Mock MCP Does
125
134
 
126
135
  Mock MCP pairs a WebSocket batch bridge with MCP tooling to move intercepted requests from tests to AI helpers and back again.
127
136
 
137
+ - **Schema-aware generation** uses your provided metadata (OpenAPI JSON Schema) to ensure mocks match production behavior.
128
138
  - **Batch-aware test client** collects every network interception inside a single macrotask and waits for the full response set.
129
139
  - **MCP tooling** exposes `get_pending_batches` and `provide_batch_mock_data` so AI agents understand the waiting requests and push data back.
130
140
  - **WebSocket bridge** connects the test runner to the MCP server while hiding transport details from both sides.
@@ -174,7 +184,7 @@ const mockClient = await connect({
174
184
 
175
185
  await page.route("**/api/users", async (route) => {
176
186
  const url = new URL(route.request().url());
177
- const data = await mockClient.requestMock(
187
+ const { data } = await mockClient.requestMock(
178
188
  url.pathname,
179
189
  route.request().method()
180
190
  );
@@ -189,9 +199,17 @@ await page.route("**/api/users", async (route) => {
189
199
 
190
200
  Batch behaviour stays automatic: additional `requestMock` calls issued in the same macrotask are grouped, forwarded, and resolved together.
191
201
 
202
+ Need to pause the test until everything in-flight resolves? Call `waitForPendingRequests` to block on the current set of pending requests (anything started after the call is not included):
203
+
204
+ ```ts
205
+ // After routing a few requests
206
+ await mockClient.waitForPendingRequests();
207
+ // Safe to assert on the results produced by the mocked responses
208
+ ```
209
+
192
210
  ## Describe Requests with Metadata
193
211
 
194
- `requestMock` accepts an optional third argument (`RequestMockOptions`) that is forwarded without modification to the MCP server. The most important field in that object is `metadata`, which lets the test process describe each request with the exact OpenAPI/JSON Schema fragment, sample payloads, or test context that the AI client needs to build a response.
212
+ `requestMock` accepts an optional third argument (`RequestMockOptions`) that is forwarded without modification to the MCP server. The most important field in that object is `metadata`, which lets the test process describe each request with the exact OpenAPI JSON Schema fragment, sample payloads, or test context that the AI client needs to build a response.
195
213
 
196
214
  When an MCP client calls `get_pending_batches`, every `requests[].metadata` entry from the test run is included in the response. That is the channel the LLM uses to understand the requested endpoint before supplying data through `provide_batch_mock_data`. Metadata is also persisted when batch logging is enabled, so you can audit what was sent to the model.
197
215
 
@@ -261,6 +279,7 @@ The library exports primitives so you can embed the workflow inside bespoke runn
261
279
 
262
280
  - `TestMockMCPServer` starts and stops the WebSocket plus MCP tooling bridge programmatically.
263
281
  - `BatchMockCollector` provides a low-level batching client used directly inside test environments.
282
+ - `BatchMockCollector.waitForPendingRequests()` waits for the currently pending mock requests to settle (resolves when all finish, rejects if any fail).
264
283
  - `connect(options)` instantiates `BatchMockCollector` and waits for the WebSocket connection to open.
265
284
 
266
285
  Each class accepts logger overrides, timeout tweaks, and other ergonomics surfaced in the technical design.
@@ -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
  };
@@ -5,7 +6,7 @@ export interface BatchMockCollectorOptions {
5
6
  /**
6
7
  * TCP port exposed by {@link TestMockMCPServer}.
7
8
  *
8
- * @default 8080
9
+ * @default 3002
9
10
  */
10
11
  port?: number;
11
12
  /**
@@ -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 readonly ws;
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 readonly readyPromise;
77
+ private readyPromise;
60
78
  constructor(options?: BatchMockCollectorOptions);
61
79
  /**
62
80
  * Ensures the underlying WebSocket connection is ready for use.
@@ -65,17 +83,28 @@ 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>>;
87
+ /**
88
+ * Wait for all requests that are currently pending to settle. Requests
89
+ * created after this method is called are not included.
90
+ */
91
+ waitForPendingRequests(): Promise<void>;
69
92
  /**
70
93
  * Close the underlying connection and fail all pending requests.
71
94
  */
72
95
  close(code?: number): Promise<void>;
73
96
  private setupWebSocket;
97
+ private createWebSocket;
98
+ private resetReadyPromise;
99
+ private startHeartbeat;
100
+ private stopHeartbeat;
101
+ private scheduleReconnect;
74
102
  private handleMessage;
75
103
  private resolveRequest;
76
104
  private enqueueRequest;
77
105
  private flushQueue;
78
106
  private sendBatch;
107
+ private buildResolvedMock;
79
108
  private rejectRequest;
80
109
  private failAllPending;
81
110
  }
@@ -1,9 +1,11 @@
1
1
  import WebSocket from "ws";
2
2
  import { BATCH_MOCK_REQUEST, BATCH_MOCK_RESPONSE, } from "../types.js";
3
+ import { isEnabled } from "./util.js";
3
4
  const DEFAULT_TIMEOUT = 60_000;
4
5
  const DEFAULT_BATCH_DEBOUNCE_MS = 0;
5
6
  const DEFAULT_MAX_BATCH_SIZE = 50;
6
- const DEFAULT_PORT = 8080;
7
+ const DEFAULT_PORT = 3002;
8
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
7
9
  /**
8
10
  * Collects HTTP requests issued during a single macrotask and forwards them to
9
11
  * the MCP server as a batch for AI-assisted mock generation.
@@ -16,24 +18,28 @@ export class BatchMockCollector {
16
18
  batchDebounceMs;
17
19
  maxBatchSize;
18
20
  logger;
21
+ heartbeatIntervalMs;
22
+ enableReconnect;
23
+ port;
19
24
  batchTimer = null;
25
+ heartbeatTimer = null;
26
+ reconnectTimer = null;
20
27
  requestIdCounter = 0;
21
28
  closed = false;
22
29
  readyResolve;
23
30
  readyReject;
24
- readyPromise;
31
+ readyPromise = Promise.resolve();
25
32
  constructor(options = {}) {
26
33
  this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
27
34
  this.batchDebounceMs = options.batchDebounceMs ?? DEFAULT_BATCH_DEBOUNCE_MS;
28
35
  this.maxBatchSize = options.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
29
36
  this.logger = options.logger ?? console;
30
- const port = options.port ?? DEFAULT_PORT;
31
- this.readyPromise = new Promise((resolve, reject) => {
32
- this.readyResolve = resolve;
33
- this.readyReject = reject;
34
- });
35
- const wsUrl = `ws://localhost:${port}`;
36
- 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();
37
43
  this.setupWebSocket();
38
44
  }
39
45
  /**
@@ -59,24 +65,45 @@ export class BatchMockCollector {
59
65
  headers: options.headers,
60
66
  metadata: options.metadata,
61
67
  };
68
+ let settleCompletion;
69
+ const completion = new Promise((resolve) => {
70
+ settleCompletion = resolve;
71
+ });
62
72
  return new Promise((resolve, reject) => {
63
73
  const timeoutId = setTimeout(() => {
64
- this.pendingRequests.delete(requestId);
65
- reject(new Error(`Mock request timed out after ${this.timeout}ms: ${method} ${endpoint}`));
74
+ this.rejectRequest(requestId, new Error(`Mock request timed out after ${this.timeout}ms: ${method} ${endpoint}`));
66
75
  }, this.timeout);
67
76
  this.pendingRequests.set(requestId, {
68
77
  request,
69
- resolve: (data) => {
70
- resolve(data);
78
+ resolve: (mock) => {
79
+ settleCompletion({ status: "fulfilled", value: undefined });
80
+ resolve(this.buildResolvedMock(mock));
71
81
  },
72
82
  reject: (error) => {
83
+ settleCompletion({ status: "rejected", reason: error });
73
84
  reject(error);
74
85
  },
75
86
  timeoutId,
87
+ completion,
76
88
  });
77
89
  this.enqueueRequest(requestId);
78
90
  });
79
91
  }
92
+ /**
93
+ * Wait for all requests that are currently pending to settle. Requests
94
+ * created after this method is called are not included.
95
+ */
96
+ async waitForPendingRequests() {
97
+ if (!isEnabled()) {
98
+ return;
99
+ }
100
+ const pendingCompletions = Array.from(this.pendingRequests.values()).map((pending) => pending.completion);
101
+ const results = await Promise.all(pendingCompletions);
102
+ const rejected = results.find((result) => result.status === "rejected");
103
+ if (rejected) {
104
+ throw rejected.reason;
105
+ }
106
+ }
80
107
  /**
81
108
  * Close the underlying connection and fail all pending requests.
82
109
  */
@@ -89,6 +116,14 @@ export class BatchMockCollector {
89
116
  clearTimeout(this.batchTimer);
90
117
  this.batchTimer = null;
91
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
+ }
92
127
  this.queuedRequestIds.clear();
93
128
  const closePromise = new Promise((resolve) => {
94
129
  this.ws.once("close", () => resolve());
@@ -101,6 +136,7 @@ export class BatchMockCollector {
101
136
  this.ws.on("open", () => {
102
137
  this.logger.log("🔌 Connected to mock MCP WebSocket endpoint");
103
138
  this.readyResolve?.();
139
+ this.startHeartbeat();
104
140
  });
105
141
  this.ws.on("message", (data) => this.handleMessage(data));
106
142
  this.ws.on("error", (error) => {
@@ -110,9 +146,65 @@ export class BatchMockCollector {
110
146
  });
111
147
  this.ws.on("close", () => {
112
148
  this.logger.warn("🔌 WebSocket connection closed");
149
+ this.stopHeartbeat();
113
150
  this.failAllPending(new Error("WebSocket connection closed"));
151
+ if (!this.closed && this.enableReconnect) {
152
+ this.scheduleReconnect();
153
+ }
114
154
  });
115
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();
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?.();
207
+ }
116
208
  handleMessage(data) {
117
209
  let parsed;
118
210
  try {
@@ -139,7 +231,13 @@ export class BatchMockCollector {
139
231
  }
140
232
  clearTimeout(pending.timeoutId);
141
233
  this.pendingRequests.delete(mock.requestId);
142
- pending.resolve(mock.data);
234
+ const resolve = () => pending.resolve(mock);
235
+ if (mock.delayMs && mock.delayMs > 0) {
236
+ setTimeout(resolve, mock.delayMs);
237
+ }
238
+ else {
239
+ resolve();
240
+ }
143
241
  }
144
242
  enqueueRequest(requestId) {
145
243
  this.queuedRequestIds.add(requestId);
@@ -184,6 +282,15 @@ export class BatchMockCollector {
184
282
  this.logger.debug?.(`📤 Sending batch with ${requests.length} request(s) to MCP server`);
185
283
  this.ws.send(JSON.stringify(payload));
186
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
+ }
187
294
  rejectRequest(requestId, error) {
188
295
  const pending = this.pendingRequests.get(requestId);
189
296
  if (!pending) {
@@ -1,8 +1,14 @@
1
- import { BatchMockCollector } from "./batch-mock-collector.js";
2
- import type { BatchMockCollectorOptions } from "./batch-mock-collector.js";
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<BatchMockCollector | void>;
14
+ export declare const connect: (options?: ConnectOptions) => Promise<MockClient>;
@@ -1,15 +1,29 @@
1
1
  import { BatchMockCollector } from "./batch-mock-collector.js";
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
+ }
2
17
  /**
3
18
  * Convenience helper that creates a {@link BatchMockCollector} and waits for the
4
19
  * underlying WebSocket connection to become ready before resolving.
5
20
  */
6
21
  export const connect = async (options) => {
7
- const isEnabled = process.env.MOCK_MCP !== undefined && process.env.MOCK_MCP !== "0";
8
- if (!isEnabled) {
22
+ const resolvedOptions = typeof options === "number" ? { port: options } : options ?? {};
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;
@@ -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";
@@ -0,0 +1 @@
1
+ export declare const isEnabled: () => boolean;
@@ -0,0 +1,3 @@
1
+ export const isEnabled = () => {
2
+ return process.env.MOCK_MCP !== undefined && process.env.MOCK_MCP !== "0";
3
+ };
package/dist/connect.cjs CHANGED
@@ -41,11 +41,17 @@ var import_ws = __toESM(require("ws"), 1);
41
41
  var BATCH_MOCK_REQUEST = "BATCH_MOCK_REQUEST";
42
42
  var BATCH_MOCK_RESPONSE = "BATCH_MOCK_RESPONSE";
43
43
 
44
+ // src/client/util.ts
45
+ var isEnabled = () => {
46
+ return process.env.MOCK_MCP !== void 0 && process.env.MOCK_MCP !== "0";
47
+ };
48
+
44
49
  // src/client/batch-mock-collector.ts
45
50
  var DEFAULT_TIMEOUT = 6e4;
46
51
  var DEFAULT_BATCH_DEBOUNCE_MS = 0;
47
52
  var DEFAULT_MAX_BATCH_SIZE = 50;
48
- var DEFAULT_PORT = 8080;
53
+ var DEFAULT_PORT = 3002;
54
+ var DEFAULT_HEARTBEAT_INTERVAL_MS = 15e3;
49
55
  var BatchMockCollector = class {
50
56
  ws;
51
57
  pendingRequests = /* @__PURE__ */ new Map();
@@ -54,24 +60,27 @@ var BatchMockCollector = class {
54
60
  batchDebounceMs;
55
61
  maxBatchSize;
56
62
  logger;
63
+ heartbeatIntervalMs;
64
+ enableReconnect;
65
+ port;
57
66
  batchTimer = null;
67
+ heartbeatTimer = null;
68
+ reconnectTimer = null;
58
69
  requestIdCounter = 0;
59
70
  closed = false;
60
71
  readyResolve;
61
72
  readyReject;
62
- readyPromise;
73
+ readyPromise = Promise.resolve();
63
74
  constructor(options = {}) {
64
75
  this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
65
76
  this.batchDebounceMs = options.batchDebounceMs ?? DEFAULT_BATCH_DEBOUNCE_MS;
66
77
  this.maxBatchSize = options.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
67
78
  this.logger = options.logger ?? console;
68
- const port = options.port ?? DEFAULT_PORT;
69
- this.readyPromise = new Promise((resolve, reject) => {
70
- this.readyResolve = resolve;
71
- this.readyReject = reject;
72
- });
73
- const wsUrl = `ws://localhost:${port}`;
74
- 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();
75
84
  this.setupWebSocket();
76
85
  }
77
86
  /**
@@ -97,10 +106,14 @@ var BatchMockCollector = class {
97
106
  headers: options.headers,
98
107
  metadata: options.metadata
99
108
  };
109
+ let settleCompletion;
110
+ const completion = new Promise((resolve) => {
111
+ settleCompletion = resolve;
112
+ });
100
113
  return new Promise((resolve, reject) => {
101
114
  const timeoutId = setTimeout(() => {
102
- this.pendingRequests.delete(requestId);
103
- reject(
115
+ this.rejectRequest(
116
+ requestId,
104
117
  new Error(
105
118
  `Mock request timed out after ${this.timeout}ms: ${method} ${endpoint}`
106
119
  )
@@ -108,17 +121,39 @@ var BatchMockCollector = class {
108
121
  }, this.timeout);
109
122
  this.pendingRequests.set(requestId, {
110
123
  request,
111
- resolve: (data) => {
112
- resolve(data);
124
+ resolve: (mock) => {
125
+ settleCompletion({ status: "fulfilled", value: void 0 });
126
+ resolve(this.buildResolvedMock(mock));
113
127
  },
114
128
  reject: (error) => {
129
+ settleCompletion({ status: "rejected", reason: error });
115
130
  reject(error);
116
131
  },
117
- timeoutId
132
+ timeoutId,
133
+ completion
118
134
  });
119
135
  this.enqueueRequest(requestId);
120
136
  });
121
137
  }
138
+ /**
139
+ * Wait for all requests that are currently pending to settle. Requests
140
+ * created after this method is called are not included.
141
+ */
142
+ async waitForPendingRequests() {
143
+ if (!isEnabled()) {
144
+ return;
145
+ }
146
+ const pendingCompletions = Array.from(this.pendingRequests.values()).map(
147
+ (pending) => pending.completion
148
+ );
149
+ const results = await Promise.all(pendingCompletions);
150
+ const rejected = results.find(
151
+ (result) => result.status === "rejected"
152
+ );
153
+ if (rejected) {
154
+ throw rejected.reason;
155
+ }
156
+ }
122
157
  /**
123
158
  * Close the underlying connection and fail all pending requests.
124
159
  */
@@ -131,6 +166,14 @@ var BatchMockCollector = class {
131
166
  clearTimeout(this.batchTimer);
132
167
  this.batchTimer = null;
133
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
+ }
134
177
  this.queuedRequestIds.clear();
135
178
  const closePromise = new Promise((resolve) => {
136
179
  this.ws.once("close", () => resolve());
@@ -143,6 +186,7 @@ var BatchMockCollector = class {
143
186
  this.ws.on("open", () => {
144
187
  this.logger.log("\u{1F50C} Connected to mock MCP WebSocket endpoint");
145
188
  this.readyResolve?.();
189
+ this.startHeartbeat();
146
190
  });
147
191
  this.ws.on("message", (data) => this.handleMessage(data));
148
192
  this.ws.on("error", (error) => {
@@ -156,8 +200,66 @@ var BatchMockCollector = class {
156
200
  });
157
201
  this.ws.on("close", () => {
158
202
  this.logger.warn("\u{1F50C} WebSocket connection closed");
203
+ this.stopHeartbeat();
159
204
  this.failAllPending(new Error("WebSocket connection closed"));
205
+ if (!this.closed && this.enableReconnect) {
206
+ this.scheduleReconnect();
207
+ }
208
+ });
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();
160
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?.();
161
263
  }
162
264
  handleMessage(data) {
163
265
  let parsed;
@@ -186,7 +288,12 @@ var BatchMockCollector = class {
186
288
  }
187
289
  clearTimeout(pending.timeoutId);
188
290
  this.pendingRequests.delete(mock.requestId);
189
- pending.resolve(mock.data);
291
+ const resolve = () => pending.resolve(mock);
292
+ if (mock.delayMs && mock.delayMs > 0) {
293
+ setTimeout(resolve, mock.delayMs);
294
+ } else {
295
+ resolve();
296
+ }
190
297
  }
191
298
  enqueueRequest(requestId) {
192
299
  this.queuedRequestIds.add(requestId);
@@ -235,6 +342,15 @@ var BatchMockCollector = class {
235
342
  );
236
343
  this.ws.send(JSON.stringify(payload));
237
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
+ }
238
354
  rejectRequest(requestId, error) {
239
355
  const pending = this.pendingRequests.get(requestId);
240
356
  if (!pending) {
@@ -252,13 +368,28 @@ var BatchMockCollector = class {
252
368
  };
253
369
 
254
370
  // src/client/connect.ts
255
- var connect = async (options) => {
256
- const isEnabled = process.env.MOCK_MCP !== void 0 && process.env.MOCK_MCP !== "0";
257
- if (!isEnabled) {
258
- console.log("[mock-mcp] Skipping (set MOCK_MCP=1 to enable)");
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() {
259
381
  return;
260
382
  }
383
+ async close() {
384
+ return;
385
+ }
386
+ };
387
+ var connect = async (options) => {
261
388
  const resolvedOptions = typeof options === "number" ? { port: options } : options ?? {};
389
+ if (!isEnabled()) {
390
+ console.log("[mock-mcp] Skipping (set MOCK_MCP=1 to enable)");
391
+ return new DisabledMockClient();
392
+ }
262
393
  const collector = new BatchMockCollector(resolvedOptions);
263
394
  await collector.waitUntilReady();
264
395
  return collector;
@@ -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
  };
@@ -5,7 +19,7 @@ interface BatchMockCollectorOptions {
5
19
  /**
6
20
  * TCP port exposed by {@link TestMockMCPServer}.
7
21
  *
8
- * @default 8080
22
+ * @default 3002
9
23
  */
10
24
  port?: number;
11
25
  /**
@@ -33,58 +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
- * Collects HTTP requests issued during a single macrotask and forwards them to
44
- * the MCP server as a batch for AI-assisted mock generation.
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
- * Close the underlying connection and fail all pending requests.
71
- */
72
+ requestMock<T = unknown>(endpoint: string, method: string, options?: RequestMockOptions): Promise<ResolvedMock<T>>;
73
+ waitForPendingRequests(): Promise<void>;
72
74
  close(code?: number): Promise<void>;
73
- private setupWebSocket;
74
- private handleMessage;
75
- private resolveRequest;
76
- private enqueueRequest;
77
- private flushQueue;
78
- private sendBatch;
79
- private rejectRequest;
80
- private failAllPending;
81
75
  }
82
-
83
- type ConnectOptions = number | BatchMockCollectorOptions | undefined;
84
76
  /**
85
77
  * Convenience helper that creates a {@link BatchMockCollector} and waits for the
86
78
  * underlying WebSocket connection to become ready before resolving.
87
79
  */
88
- declare const connect: (options?: ConnectOptions) => Promise<BatchMockCollector | void>;
80
+ declare const connect: (options?: ConnectOptions) => Promise<MockClient>;
89
81
 
90
- 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 missing = mocks.find((mock) => !batch.requests.some((request) => request.requestId === mock.requestId));
113
- if (missing) {
114
- throw new Error(`Mock data references unknown requestId: ${missing.requestId}`);
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
- type: "object",
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[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mock-mcp",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "An MCP server enabling LLMs to write integration tests through live test environment interaction",
5
5
  "main": "./dist/connect.cjs",
6
6
  "type": "module",
@@ -70,4 +70,4 @@
70
70
  "path": "cz-conventional-changelog"
71
71
  }
72
72
  }
73
- }
73
+ }