mock-mcp 0.5.0 → 0.5.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.
@@ -1,111 +1,20 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
- import crypto2 from 'crypto';
5
4
  import http from 'http';
5
+ import crypto2 from 'crypto';
6
6
  import fs from 'fs/promises';
7
- import fssync from 'fs';
7
+ import 'fs';
8
8
  import os from 'os';
9
9
  import path from 'path';
10
- import { spawn } from 'child_process';
11
- import { fileURLToPath, pathToFileURL } from 'url';
12
- import { createRequire } from 'module';
10
+ import 'child_process';
11
+ import { fileURLToPath } from 'url';
12
+ import 'module';
13
13
 
14
14
  // src/adapter/adapter.ts
15
- var DaemonClient = class {
16
- constructor(ipcPath, token, adapterId) {
17
- this.ipcPath = ipcPath;
18
- this.token = token;
19
- this.adapterId = adapterId;
20
- }
21
- // ===========================================================================
22
- // RPC Methods
23
- // ===========================================================================
24
- async getStatus() {
25
- return this.rpc("getStatus", {});
26
- }
27
- async listRuns() {
28
- return this.rpc("listRuns", {});
29
- }
30
- async claimNextBatch(args) {
31
- return this.rpc("claimNextBatch", {
32
- adapterId: this.adapterId,
33
- runId: args.runId,
34
- leaseMs: args.leaseMs
35
- });
36
- }
37
- async provideBatch(args) {
38
- return this.rpc("provideBatch", {
39
- adapterId: this.adapterId,
40
- batchId: args.batchId,
41
- claimToken: args.claimToken,
42
- mocks: args.mocks
43
- });
44
- }
45
- async releaseBatch(args) {
46
- return this.rpc("releaseBatch", {
47
- adapterId: this.adapterId,
48
- batchId: args.batchId,
49
- claimToken: args.claimToken,
50
- reason: args.reason
51
- });
52
- }
53
- async getBatch(batchId) {
54
- return this.rpc("getBatch", { batchId });
55
- }
56
- // ===========================================================================
57
- // Internal
58
- // ===========================================================================
59
- rpc(method, params) {
60
- const payload = {
61
- jsonrpc: "2.0",
62
- id: crypto2.randomUUID(),
63
- method,
64
- params
65
- };
66
- return new Promise((resolve, reject) => {
67
- const req = http.request(
68
- {
69
- method: "POST",
70
- socketPath: this.ipcPath,
71
- path: "/control",
72
- headers: {
73
- "content-type": "application/json",
74
- "x-mock-mcp-token": this.token
75
- },
76
- timeout: 3e4
77
- },
78
- (res) => {
79
- let buf = "";
80
- res.on("data", (chunk) => buf += chunk);
81
- res.on("end", () => {
82
- try {
83
- const response = JSON.parse(buf);
84
- if (response.error) {
85
- reject(new Error(response.error.message));
86
- } else {
87
- resolve(response.result);
88
- }
89
- } catch (e) {
90
- reject(e);
91
- }
92
- });
93
- }
94
- );
95
- req.on("error", (err) => {
96
- reject(new Error(`Daemon connection failed: ${err.message}`));
97
- });
98
- req.on("timeout", () => {
99
- req.destroy();
100
- reject(new Error("Daemon request timeout"));
101
- });
102
- req.end(JSON.stringify(payload));
103
- });
104
- }
105
- };
106
15
  function debugLog(_msg) {
107
16
  }
108
- var __curDirname = (() => {
17
+ (() => {
109
18
  try {
110
19
  const metaUrl = import.meta.url;
111
20
  if (metaUrl && typeof metaUrl === "string" && metaUrl.startsWith("file://")) {
@@ -115,32 +24,6 @@ var __curDirname = (() => {
115
24
  }
116
25
  return process.cwd();
117
26
  })();
118
- function resolveProjectRoot(startDir = process.cwd()) {
119
- let current = path.resolve(startDir);
120
- const root = path.parse(current).root;
121
- while (current !== root) {
122
- const gitPath = path.join(current, ".git");
123
- try {
124
- const stat = fssync.statSync(gitPath);
125
- if (stat.isDirectory() || stat.isFile()) {
126
- return current;
127
- }
128
- } catch {
129
- }
130
- const pkgPath = path.join(current, "package.json");
131
- try {
132
- fssync.accessSync(pkgPath, fssync.constants.F_OK);
133
- return current;
134
- } catch {
135
- }
136
- current = path.dirname(current);
137
- }
138
- return path.resolve(startDir);
139
- }
140
- function computeProjectId(projectRoot) {
141
- const real = fssync.realpathSync(projectRoot);
142
- return crypto2.createHash("sha256").update(real).digest("hex").slice(0, 16);
143
- }
144
27
  function getCacheDir(override) {
145
28
  if (override) {
146
29
  return override;
@@ -162,13 +45,6 @@ function getCacheDir(override) {
162
45
  }
163
46
  return os.tmpdir();
164
47
  }
165
- function getPaths(projectId, cacheDir) {
166
- const base = path.join(getCacheDir(cacheDir), "mock-mcp");
167
- const registryPath = path.join(base, `${projectId}.json`);
168
- const lockPath = path.join(base, `${projectId}.lock`);
169
- const ipcPath = process.platform === "win32" ? `\\\\.\\pipe\\mock-mcp-${projectId}` : path.join(base, `${projectId}.sock`);
170
- return { base, registryPath, lockPath, ipcPath };
171
- }
172
48
  async function readRegistry(registryPath) {
173
49
  try {
174
50
  const txt = await fs.readFile(registryPath, "utf-8");
@@ -199,157 +75,310 @@ async function healthCheck(ipcPath, timeoutMs = 2e3) {
199
75
  req.end();
200
76
  });
201
77
  }
202
- async function tryAcquireLock(lockPath) {
78
+ function getGlobalIndexPath(cacheDir) {
79
+ const base = path.join(getCacheDir(cacheDir), "mock-mcp");
80
+ return path.join(base, "active-daemons.json");
81
+ }
82
+ async function readGlobalIndex(cacheDir) {
83
+ const indexPath = getGlobalIndexPath(cacheDir);
203
84
  try {
204
- const fh = await fs.open(lockPath, "wx");
205
- await fh.write(`${process.pid}
206
- `);
207
- return fh;
85
+ const txt = await fs.readFile(indexPath, "utf-8");
86
+ return JSON.parse(txt);
208
87
  } catch {
209
- return null;
88
+ return { daemons: [], updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
210
89
  }
211
90
  }
212
- async function releaseLock(lockPath, fh) {
213
- await fh.close();
214
- await fs.rm(lockPath).catch(() => {
91
+ async function writeGlobalIndex(index, cacheDir) {
92
+ const indexPath = getGlobalIndexPath(cacheDir);
93
+ const base = path.dirname(indexPath);
94
+ await fs.mkdir(base, { recursive: true });
95
+ await fs.writeFile(indexPath, JSON.stringify(index, null, 2), {
96
+ encoding: "utf-8",
97
+ mode: 384
215
98
  });
216
99
  }
217
- function randomToken() {
218
- return crypto2.randomBytes(24).toString("base64url");
100
+ async function cleanupGlobalIndex(cacheDir) {
101
+ const index = await readGlobalIndex(cacheDir);
102
+ const validDaemons = [];
103
+ for (const entry of index.daemons) {
104
+ try {
105
+ process.kill(entry.pid, 0);
106
+ const healthy = await healthCheck(entry.ipcPath, 1e3);
107
+ if (healthy) {
108
+ validDaemons.push(entry);
109
+ } else {
110
+ debugLog(`Removing unhealthy daemon ${entry.projectId} (pid ${entry.pid})`);
111
+ }
112
+ } catch {
113
+ debugLog(`Removing dead daemon ${entry.projectId} (pid ${entry.pid})`);
114
+ }
115
+ }
116
+ if (validDaemons.length !== index.daemons.length) {
117
+ index.daemons = validDaemons;
118
+ index.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
119
+ await writeGlobalIndex(index, cacheDir);
120
+ }
219
121
  }
220
- function getDaemonEntryPath() {
221
- try {
222
- const cwdRequire = createRequire(pathToFileURL(path.join(process.cwd(), "index.js")).href);
223
- const resolved = cwdRequire.resolve("mock-mcp");
224
- const distDir = path.dirname(resolved);
225
- const daemonEntry = path.join(distDir, "index.js");
226
- if (fssync.existsSync(daemonEntry)) {
227
- return daemonEntry;
122
+ async function discoverAllDaemons(cacheDir) {
123
+ await cleanupGlobalIndex(cacheDir);
124
+ const index = await readGlobalIndex(cacheDir);
125
+ const results = [];
126
+ for (const entry of index.daemons) {
127
+ const registry = await readRegistry(entry.registryPath);
128
+ if (registry) {
129
+ const healthy = await healthCheck(entry.ipcPath, 2e3);
130
+ results.push({ registry, healthy });
228
131
  }
229
- } catch {
230
132
  }
231
- try {
232
- const packageRoot = resolveProjectRoot(__curDirname);
233
- const distPath = path.join(packageRoot, "dist", "index.js");
234
- if (fssync.existsSync(distPath)) {
235
- return distPath;
133
+ return results;
134
+ }
135
+
136
+ // src/adapter/multi-daemon-client.ts
137
+ var MultiDaemonClient = class {
138
+ logger;
139
+ cacheDir;
140
+ adapterId;
141
+ constructor(opts = {}) {
142
+ this.logger = opts.logger ?? console;
143
+ this.cacheDir = opts.cacheDir;
144
+ this.adapterId = crypto2.randomUUID();
145
+ }
146
+ // ===========================================================================
147
+ // Discovery
148
+ // ===========================================================================
149
+ /**
150
+ * Discover all active and healthy daemons.
151
+ */
152
+ async discoverDaemons() {
153
+ return discoverAllDaemons(this.cacheDir);
154
+ }
155
+ // ===========================================================================
156
+ // Aggregated RPC Methods
157
+ // ===========================================================================
158
+ /**
159
+ * Get aggregated status from all daemons.
160
+ */
161
+ async getAggregatedStatus() {
162
+ const daemons = await this.discoverDaemons();
163
+ const statuses = [];
164
+ let totalRuns = 0;
165
+ let totalPending = 0;
166
+ let totalClaimed = 0;
167
+ for (const { registry, healthy } of daemons) {
168
+ if (!healthy) {
169
+ statuses.push({
170
+ version: registry.version,
171
+ projectId: registry.projectId,
172
+ projectRoot: registry.projectRoot,
173
+ pid: registry.pid,
174
+ uptime: 0,
175
+ runs: 0,
176
+ pending: 0,
177
+ claimed: 0,
178
+ totalBatches: 0,
179
+ healthy: false
180
+ });
181
+ continue;
182
+ }
183
+ try {
184
+ const status = await this.rpc(registry, "getStatus", {});
185
+ statuses.push({ ...status, healthy: true });
186
+ totalRuns += status.runs;
187
+ totalPending += status.pending;
188
+ totalClaimed += status.claimed;
189
+ } catch (error) {
190
+ this.logger.warn(`Failed to get status from daemon ${registry.projectId}: ${error}`);
191
+ statuses.push({
192
+ version: registry.version,
193
+ projectId: registry.projectId,
194
+ projectRoot: registry.projectRoot,
195
+ pid: registry.pid,
196
+ uptime: 0,
197
+ runs: 0,
198
+ pending: 0,
199
+ claimed: 0,
200
+ totalBatches: 0,
201
+ healthy: false
202
+ });
203
+ }
236
204
  }
237
- } catch {
205
+ return { daemons: statuses, totalRuns, totalPending, totalClaimed };
206
+ }
207
+ /**
208
+ * List all runs across all daemons.
209
+ */
210
+ async listAllRuns() {
211
+ const daemons = await this.discoverDaemons();
212
+ const allRuns = [];
213
+ for (const { registry, healthy } of daemons) {
214
+ if (!healthy) continue;
215
+ try {
216
+ const result = await this.rpc(registry, "listRuns", {});
217
+ for (const run of result.runs) {
218
+ allRuns.push({
219
+ ...run,
220
+ projectId: registry.projectId,
221
+ projectRoot: registry.projectRoot
222
+ });
223
+ }
224
+ } catch (error) {
225
+ this.logger.warn(`Failed to list runs from daemon ${registry.projectId}: ${error}`);
226
+ }
227
+ }
228
+ return allRuns;
238
229
  }
239
- if (process.argv[1]) {
240
- return process.argv[1];
230
+ /**
231
+ * Claim the next available batch from any daemon.
232
+ * Searches through all daemons in order until finding one with a pending batch.
233
+ */
234
+ async claimNextBatch(args) {
235
+ const daemons = await this.discoverDaemons();
236
+ for (const { registry, healthy } of daemons) {
237
+ if (!healthy) continue;
238
+ try {
239
+ const result = await this.rpc(registry, "claimNextBatch", {
240
+ adapterId: this.adapterId,
241
+ runId: args.runId,
242
+ leaseMs: args.leaseMs
243
+ });
244
+ if (result) {
245
+ return {
246
+ ...result,
247
+ projectId: registry.projectId,
248
+ projectRoot: registry.projectRoot
249
+ };
250
+ }
251
+ } catch (error) {
252
+ this.logger.warn(`Failed to claim batch from daemon ${registry.projectId}: ${error}`);
253
+ }
254
+ }
255
+ return null;
241
256
  }
242
- return path.join(process.cwd(), "dist", "index.js");
243
- }
244
- async function ensureDaemonRunning(opts = {}) {
245
- const projectRoot = opts.projectRoot ?? resolveProjectRoot();
246
- const projectId = computeProjectId(projectRoot);
247
- const { base, registryPath, lockPath, ipcPath } = getPaths(
248
- projectId,
249
- opts.cacheDir
250
- );
251
- const timeoutMs = opts.timeoutMs ?? 1e4;
252
- await fs.mkdir(base, { recursive: true });
253
- const existing = await readRegistry(registryPath);
254
- debugLog(`Registry read result: ${existing ? "Found (PID " + existing.pid + ")" : "Null"}`);
255
- if (existing) {
256
- let healthy = false;
257
- for (let i = 0; i < 3; i++) {
258
- debugLog(`Checking health attempt ${i + 1}/3 on ${existing.ipcPath}`);
259
- healthy = await healthCheck(existing.ipcPath);
260
- if (healthy) break;
261
- await new Promise((r) => setTimeout(r, 200));
257
+ /**
258
+ * Provide mock data for a batch.
259
+ * Automatically routes to the correct daemon based on batchId.
260
+ */
261
+ async provideBatch(args) {
262
+ const parts = args.batchId.split(":");
263
+ if (parts.length < 2) {
264
+ return { ok: false, message: `Invalid batchId format: ${args.batchId}` };
262
265
  }
263
- if (healthy) {
264
- return existing;
266
+ const daemons = await this.discoverDaemons();
267
+ for (const { registry, healthy } of daemons) {
268
+ if (!healthy) continue;
269
+ try {
270
+ const result = await this.rpc(registry, "provideBatch", {
271
+ adapterId: this.adapterId,
272
+ batchId: args.batchId,
273
+ claimToken: args.claimToken,
274
+ mocks: args.mocks
275
+ });
276
+ return result;
277
+ } catch (error) {
278
+ const msg = error instanceof Error ? error.message : String(error);
279
+ if (msg.includes("not found") || msg.includes("Not found")) {
280
+ continue;
281
+ }
282
+ return { ok: false, message: msg };
283
+ }
265
284
  }
285
+ return { ok: false, message: `Batch not found: ${args.batchId}` };
266
286
  }
267
- if (process.platform !== "win32") {
268
- try {
269
- await fs.rm(ipcPath);
270
- } catch {
287
+ /**
288
+ * Release a batch.
289
+ */
290
+ async releaseBatch(args) {
291
+ const daemons = await this.discoverDaemons();
292
+ for (const { registry, healthy } of daemons) {
293
+ if (!healthy) continue;
294
+ try {
295
+ const result = await this.rpc(registry, "releaseBatch", {
296
+ adapterId: this.adapterId,
297
+ batchId: args.batchId,
298
+ claimToken: args.claimToken,
299
+ reason: args.reason
300
+ });
301
+ return result;
302
+ } catch (error) {
303
+ const msg = error instanceof Error ? error.message : String(error);
304
+ if (msg.includes("not found") || msg.includes("Not found")) {
305
+ continue;
306
+ }
307
+ return { ok: false, message: msg };
308
+ }
271
309
  }
310
+ return { ok: false, message: `Batch not found: ${args.batchId}` };
272
311
  }
273
- const lock = await tryAcquireLock(lockPath);
274
- if (lock) {
275
- try {
276
- const recheckReg = await readRegistry(registryPath);
277
- if (recheckReg && await healthCheck(recheckReg.ipcPath)) {
278
- return recheckReg;
312
+ /**
313
+ * Get a specific batch by ID.
314
+ */
315
+ async getBatch(batchId) {
316
+ const daemons = await this.discoverDaemons();
317
+ for (const { registry, healthy } of daemons) {
318
+ if (!healthy) continue;
319
+ try {
320
+ const result = await this.rpc(registry, "getBatch", { batchId });
321
+ return result;
322
+ } catch (error) {
323
+ const msg = error instanceof Error ? error.message : String(error);
324
+ if (msg.includes("not found") || msg.includes("Not found")) {
325
+ continue;
326
+ }
327
+ throw error;
279
328
  }
280
- const token = randomToken();
281
- const daemonEntry = getDaemonEntryPath();
282
- const child = spawn(
283
- process.execPath,
284
- [daemonEntry, "daemon", "--project-root", projectRoot, "--token", token],
329
+ }
330
+ return null;
331
+ }
332
+ // ===========================================================================
333
+ // Internal RPC
334
+ // ===========================================================================
335
+ rpc(registry, method, params) {
336
+ const payload = {
337
+ jsonrpc: "2.0",
338
+ id: crypto2.randomUUID(),
339
+ method,
340
+ params
341
+ };
342
+ return new Promise((resolve, reject) => {
343
+ const req = http.request(
285
344
  {
286
- detached: true,
287
- stdio: ["ignore", "pipe", "pipe"],
288
- env: {
289
- ...process.env,
290
- MOCK_MCP_CACHE_DIR: opts.cacheDir ?? ""
291
- }
345
+ method: "POST",
346
+ socketPath: registry.ipcPath,
347
+ path: "/control",
348
+ headers: {
349
+ "content-type": "application/json",
350
+ "x-mock-mcp-token": registry.token
351
+ },
352
+ timeout: 3e4
353
+ },
354
+ (res) => {
355
+ let buf = "";
356
+ res.on("data", (chunk) => buf += chunk);
357
+ res.on("end", () => {
358
+ try {
359
+ const response = JSON.parse(buf);
360
+ if (response.error) {
361
+ reject(new Error(response.error.message));
362
+ } else {
363
+ resolve(response.result);
364
+ }
365
+ } catch (e) {
366
+ reject(e);
367
+ }
368
+ });
292
369
  }
293
370
  );
294
- let daemonStderr = "";
295
- let daemonStdout = "";
296
- child.stdout?.on("data", (data) => {
297
- const str = data.toString();
298
- debugLog(`Daemon stdout: ${str}`);
299
- });
300
- child.stderr?.on("data", (data) => {
301
- daemonStderr += data.toString();
302
- debugLog(`Daemon stderr: ${data.toString()}`);
303
- });
304
- child.on("error", (err) => {
305
- console.error(`[mock-mcp] Daemon spawn error: ${err.message}`);
371
+ req.on("error", (err) => {
372
+ reject(new Error(`Daemon connection failed: ${err.message}`));
306
373
  });
307
- child.on("exit", (code, signal) => {
308
- if (code !== null && code !== 0) {
309
- console.error(`[mock-mcp] Daemon exited with code: ${code}`);
310
- if (daemonStderr) {
311
- console.error(`[mock-mcp] Daemon stderr: ${daemonStderr.slice(0, 500)}`);
312
- }
313
- } else if (signal) {
314
- console.error(`[mock-mcp] Daemon killed by signal: ${signal}`);
315
- }
374
+ req.on("timeout", () => {
375
+ req.destroy();
376
+ reject(new Error("Daemon request timeout"));
316
377
  });
317
- child.unref();
318
- const deadline2 = Date.now() + timeoutMs;
319
- while (Date.now() < deadline2) {
320
- const reg = await readRegistry(registryPath);
321
- if (reg && await healthCheck(reg.ipcPath)) {
322
- return reg;
323
- }
324
- await sleep(50);
325
- }
326
- console.error("[mock-mcp] Daemon failed to start within timeout");
327
- if (daemonStderr) {
328
- console.error(`[mock-mcp] Daemon stderr:
329
- ${daemonStderr}`);
330
- }
331
- throw new Error(
332
- `Daemon start timeout after ${timeoutMs}ms. Check logs for details.`
333
- );
334
- } finally {
335
- await releaseLock(lockPath, lock);
336
- }
337
- }
338
- const deadline = Date.now() + timeoutMs;
339
- while (Date.now() < deadline) {
340
- const reg = await readRegistry(registryPath);
341
- if (reg && await healthCheck(reg.ipcPath)) {
342
- return reg;
343
- }
344
- await sleep(50);
378
+ req.end(JSON.stringify(payload));
379
+ });
345
380
  }
346
- throw new Error(
347
- `Waiting for daemon timed out after ${timeoutMs}ms. Another process may have failed to start it.`
348
- );
349
- }
350
- function sleep(ms) {
351
- return new Promise((resolve) => setTimeout(resolve, ms));
352
- }
381
+ };
353
382
 
354
383
  // src/adapter/adapter.ts
355
384
  var TOOLS = [
@@ -481,12 +510,19 @@ The mocks array must contain exactly one mock for each request in the batch.`,
481
510
  ];
482
511
  async function runAdapter(opts = {}) {
483
512
  const logger = opts.logger ?? console;
484
- const version = opts.version ?? "0.4.0";
485
- logger.error("\u{1F50D} Connecting to mock-mcp daemon...");
486
- const registry = await ensureDaemonRunning();
487
- const adapterId = crypto2.randomUUID();
488
- const daemon = new DaemonClient(registry.ipcPath, registry.token, adapterId);
489
- logger.error(`\u2705 Connected to daemon (project: ${registry.projectId})`);
513
+ const version = opts.version ?? "0.5.0";
514
+ logger.error("\u{1F50D} Initializing mock-mcp adapter (multi-daemon mode)...");
515
+ const multiDaemon = new MultiDaemonClient({ logger });
516
+ const daemons = await multiDaemon.discoverDaemons();
517
+ if (daemons.length > 0) {
518
+ logger.error(`\u2705 Found ${daemons.length} active daemon(s):`);
519
+ for (const d of daemons) {
520
+ const status = d.healthy ? "healthy" : "unhealthy";
521
+ logger.error(` - ${d.registry.projectId}: ${d.registry.projectRoot} (${status})`);
522
+ }
523
+ } else {
524
+ logger.error("\u2139\uFE0F No active daemons found. Waiting for test processes to start...");
525
+ }
490
526
  const server = new Server(
491
527
  {
492
528
  name: "mock-mcp-adapter",
@@ -504,15 +540,15 @@ async function runAdapter(opts = {}) {
504
540
  try {
505
541
  switch (name) {
506
542
  case "get_status": {
507
- const result = await daemon.getStatus();
508
- return buildToolResponse(formatStatus(result));
543
+ const result = await multiDaemon.getAggregatedStatus();
544
+ return buildToolResponse(formatAggregatedStatus(result));
509
545
  }
510
546
  case "list_runs": {
511
- const result = await daemon.listRuns();
512
- return buildToolResponse(formatRuns(result));
547
+ const result = await multiDaemon.listAllRuns();
548
+ return buildToolResponse(formatExtendedRuns(result));
513
549
  }
514
550
  case "claim_next_batch": {
515
- const result = await daemon.claimNextBatch({
551
+ const result = await multiDaemon.claimNextBatch({
516
552
  runId: args?.runId,
517
553
  leaseMs: args?.leaseMs
518
554
  });
@@ -522,14 +558,17 @@ async function runAdapter(opts = {}) {
522
558
  if (!args?.batchId) {
523
559
  throw new Error("batchId is required");
524
560
  }
525
- const result = await daemon.getBatch(args.batchId);
561
+ const result = await multiDaemon.getBatch(args.batchId);
562
+ if (!result) {
563
+ throw new Error(`Batch not found: ${args.batchId}`);
564
+ }
526
565
  return buildToolResponse(formatBatch(result));
527
566
  }
528
567
  case "provide_batch_mock_data": {
529
568
  if (!args?.batchId || !args?.claimToken || !args?.mocks) {
530
569
  throw new Error("batchId, claimToken, and mocks are required");
531
570
  }
532
- const result = await daemon.provideBatch({
571
+ const result = await multiDaemon.provideBatch({
533
572
  batchId: args.batchId,
534
573
  claimToken: args.claimToken,
535
574
  mocks: args.mocks
@@ -540,7 +579,7 @@ async function runAdapter(opts = {}) {
540
579
  if (!args?.batchId || !args?.claimToken) {
541
580
  throw new Error("batchId and claimToken are required");
542
581
  }
543
- const result = await daemon.releaseBatch({
582
+ const result = await multiDaemon.releaseBatch({
544
583
  batchId: args.batchId,
545
584
  claimToken: args.claimToken,
546
585
  reason: args?.reason
@@ -552,7 +591,7 @@ async function runAdapter(opts = {}) {
552
591
  }
553
592
  } catch (error) {
554
593
  const message = error instanceof Error ? error.message : String(error);
555
- logger.error(`Tool error (${name}):`, message);
594
+ logger.error(`Tool error (${name}): ${message}`);
556
595
  return buildToolResponse(`Error: ${message}`, true);
557
596
  }
558
597
  });
@@ -566,53 +605,86 @@ function buildToolResponse(text, isError = false) {
566
605
  isError
567
606
  };
568
607
  }
569
- function formatStatus(status) {
570
- return `# Mock MCP Daemon Status
608
+ function formatAggregatedStatus(status) {
609
+ if (status.daemons.length === 0) {
610
+ return `# Mock MCP Status
571
611
 
572
- - **Version**: ${status.version}
573
- - **Project ID**: ${status.projectId}
574
- - **Project Root**: ${status.projectRoot}
575
- - **PID**: ${status.pid}
576
- - **Uptime**: ${Math.round(status.uptime / 1e3)}s
577
-
578
- ## Batches
579
- - **Pending**: ${status.pending}
580
- - **Claimed**: ${status.claimed}
581
- - **Active Runs**: ${status.runs}
612
+ No active daemons found. Start a test with \`MOCK_MCP=1\` to begin.
582
613
  `;
614
+ }
615
+ const lines = [
616
+ "# Mock MCP Status\n",
617
+ "## Summary",
618
+ `- **Active Daemons**: ${status.daemons.filter((d) => d.healthy).length}`,
619
+ `- **Total Active Runs**: ${status.totalRuns}`,
620
+ `- **Total Pending Batches**: ${status.totalPending}`,
621
+ `- **Total Claimed Batches**: ${status.totalClaimed}`,
622
+ "",
623
+ "## Daemons\n"
624
+ ];
625
+ for (const daemon of status.daemons) {
626
+ const healthIcon = daemon.healthy ? "\u2705" : "\u274C";
627
+ lines.push(`### ${healthIcon} ${daemon.projectRoot}`);
628
+ lines.push(`- **Project ID**: ${daemon.projectId}`);
629
+ lines.push(`- **Version**: ${daemon.version}`);
630
+ lines.push(`- **PID**: ${daemon.pid}`);
631
+ if (daemon.healthy) {
632
+ lines.push(`- **Uptime**: ${Math.round(daemon.uptime / 1e3)}s`);
633
+ lines.push(`- **Runs**: ${daemon.runs}`);
634
+ lines.push(`- **Pending**: ${daemon.pending}`);
635
+ lines.push(`- **Claimed**: ${daemon.claimed}`);
636
+ } else {
637
+ lines.push(`- **Status**: Not responding`);
638
+ }
639
+ lines.push("");
640
+ }
641
+ return lines.join("\n");
583
642
  }
584
- function formatRuns(result) {
585
- if (result.runs.length === 0) {
586
- return "No active test runs.";
643
+ function formatExtendedRuns(runs) {
644
+ if (runs.length === 0) {
645
+ return "No active test runs.\n\nStart a test with `MOCK_MCP=1` to begin.";
587
646
  }
588
647
  const lines = ["# Active Test Runs\n"];
589
- for (const run of result.runs) {
590
- lines.push(`## Run: ${run.runId}`);
591
- lines.push(`- **PID**: ${run.pid}`);
592
- lines.push(`- **CWD**: ${run.cwd}`);
593
- lines.push(`- **Started**: ${run.startedAt}`);
594
- lines.push(`- **Pending Batches**: ${run.pendingBatches}`);
595
- if (run.testMeta) {
596
- if (run.testMeta.testFile) {
597
- lines.push(`- **Test File**: ${run.testMeta.testFile}`);
598
- }
599
- if (run.testMeta.testName) {
600
- lines.push(`- **Test Name**: ${run.testMeta.testName}`);
648
+ const byProject = /* @__PURE__ */ new Map();
649
+ for (const run of runs) {
650
+ const key = run.projectRoot;
651
+ if (!byProject.has(key)) {
652
+ byProject.set(key, []);
653
+ }
654
+ byProject.get(key).push(run);
655
+ }
656
+ for (const [projectRoot, projectRuns] of byProject) {
657
+ lines.push(`## Project: ${projectRoot}
658
+ `);
659
+ for (const run of projectRuns) {
660
+ lines.push(`### Run: ${run.runId}`);
661
+ lines.push(`- **PID**: ${run.pid}`);
662
+ lines.push(`- **CWD**: ${run.cwd}`);
663
+ lines.push(`- **Started**: ${run.startedAt}`);
664
+ lines.push(`- **Pending Batches**: ${run.pendingBatches}`);
665
+ if (run.testMeta) {
666
+ if (run.testMeta.testFile) {
667
+ lines.push(`- **Test File**: ${run.testMeta.testFile}`);
668
+ }
669
+ if (run.testMeta.testName) {
670
+ lines.push(`- **Test Name**: ${run.testMeta.testName}`);
671
+ }
601
672
  }
673
+ lines.push("");
602
674
  }
603
- lines.push("");
604
675
  }
605
676
  return lines.join("\n");
606
677
  }
607
678
  function formatClaimResult(result) {
608
679
  if (!result) {
609
- return "No pending batches available to claim.";
680
+ return "No pending batches available to claim.\n\nMake sure a test is running with `MOCK_MCP=1` and has pending mock requests.";
610
681
  }
611
682
  const lines = [
612
683
  "# Batch Claimed Successfully\n",
613
684
  `**Batch ID**: \`${result.batchId}\``,
614
685
  `**Claim Token**: \`${result.claimToken}\``,
615
686
  `**Run ID**: ${result.runId}`,
687
+ `**Project**: ${result.projectRoot}`,
616
688
  `**Lease Until**: ${new Date(result.leaseUntil).toISOString()}`,
617
689
  "",
618
690
  "## Requests\n"
@@ -668,5 +740,96 @@ function formatProvideResult(result) {
668
740
  }
669
741
  return `\u274C Failed to provide mock data: ${result.message}`;
670
742
  }
743
+ var DaemonClient = class {
744
+ constructor(ipcPath, token, adapterId) {
745
+ this.ipcPath = ipcPath;
746
+ this.token = token;
747
+ this.adapterId = adapterId;
748
+ }
749
+ // ===========================================================================
750
+ // RPC Methods
751
+ // ===========================================================================
752
+ async getStatus() {
753
+ return this.rpc("getStatus", {});
754
+ }
755
+ async listRuns() {
756
+ return this.rpc("listRuns", {});
757
+ }
758
+ async claimNextBatch(args) {
759
+ return this.rpc("claimNextBatch", {
760
+ adapterId: this.adapterId,
761
+ runId: args.runId,
762
+ leaseMs: args.leaseMs
763
+ });
764
+ }
765
+ async provideBatch(args) {
766
+ return this.rpc("provideBatch", {
767
+ adapterId: this.adapterId,
768
+ batchId: args.batchId,
769
+ claimToken: args.claimToken,
770
+ mocks: args.mocks
771
+ });
772
+ }
773
+ async releaseBatch(args) {
774
+ return this.rpc("releaseBatch", {
775
+ adapterId: this.adapterId,
776
+ batchId: args.batchId,
777
+ claimToken: args.claimToken,
778
+ reason: args.reason
779
+ });
780
+ }
781
+ async getBatch(batchId) {
782
+ return this.rpc("getBatch", { batchId });
783
+ }
784
+ // ===========================================================================
785
+ // Internal
786
+ // ===========================================================================
787
+ rpc(method, params) {
788
+ const payload = {
789
+ jsonrpc: "2.0",
790
+ id: crypto2.randomUUID(),
791
+ method,
792
+ params
793
+ };
794
+ return new Promise((resolve, reject) => {
795
+ const req = http.request(
796
+ {
797
+ method: "POST",
798
+ socketPath: this.ipcPath,
799
+ path: "/control",
800
+ headers: {
801
+ "content-type": "application/json",
802
+ "x-mock-mcp-token": this.token
803
+ },
804
+ timeout: 3e4
805
+ },
806
+ (res) => {
807
+ let buf = "";
808
+ res.on("data", (chunk) => buf += chunk);
809
+ res.on("end", () => {
810
+ try {
811
+ const response = JSON.parse(buf);
812
+ if (response.error) {
813
+ reject(new Error(response.error.message));
814
+ } else {
815
+ resolve(response.result);
816
+ }
817
+ } catch (e) {
818
+ reject(e);
819
+ }
820
+ });
821
+ }
822
+ );
823
+ req.on("error", (err) => {
824
+ reject(new Error(`Daemon connection failed: ${err.message}`));
825
+ });
826
+ req.on("timeout", () => {
827
+ req.destroy();
828
+ reject(new Error("Daemon request timeout"));
829
+ });
830
+ req.end(JSON.stringify(payload));
831
+ });
832
+ }
833
+ };
671
834
 
672
- export { DaemonClient, runAdapter };
835
+ export { DaemonClient, MultiDaemonClient, runAdapter };