mock-mcp 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +212 -124
- package/dist/adapter/index.cjs +712 -0
- package/dist/adapter/index.d.cts +55 -0
- package/dist/adapter/index.d.ts +55 -0
- package/dist/adapter/index.js +672 -0
- package/dist/client/connect.cjs +913 -0
- package/dist/client/connect.d.cts +211 -0
- package/dist/client/connect.d.ts +204 -7
- package/dist/client/connect.js +863 -20
- package/dist/client/index.cjs +914 -0
- package/dist/client/index.d.cts +4 -0
- package/dist/client/index.d.ts +4 -2
- package/dist/client/index.js +873 -2
- package/dist/daemon/index.cjs +667 -0
- package/dist/daemon/index.d.cts +62 -0
- package/dist/daemon/index.d.ts +62 -0
- package/dist/daemon/index.js +628 -0
- package/dist/discovery-Dc2LdF8q.d.cts +105 -0
- package/dist/discovery-Dc2LdF8q.d.ts +105 -0
- package/dist/index.cjs +2238 -0
- package/dist/index.d.cts +472 -0
- package/dist/index.d.ts +472 -11
- package/dist/index.js +2185 -53
- package/dist/protocol-CiwaQFOt.d.ts +239 -0
- package/dist/protocol-xZu-wb0n.d.cts +239 -0
- package/dist/shared/index.cjs +386 -0
- package/dist/shared/index.d.cts +4 -0
- package/dist/shared/index.d.ts +4 -0
- package/dist/shared/index.js +310 -0
- package/dist/types-BKREdsyr.d.cts +32 -0
- package/dist/types-BKREdsyr.d.ts +32 -0
- package/package.json +44 -4
- package/dist/client/batch-mock-collector.d.ts +0 -111
- package/dist/client/batch-mock-collector.js +0 -308
- package/dist/client/util.d.ts +0 -1
- package/dist/client/util.js +0 -3
- package/dist/connect.cjs +0 -400
- package/dist/connect.d.cts +0 -82
- package/dist/server/index.d.ts +0 -1
- package/dist/server/index.js +0 -1
- package/dist/server/test-mock-mcp-server.d.ts +0 -73
- package/dist/server/test-mock-mcp-server.js +0 -419
- package/dist/types.d.ts +0 -45
- package/dist/types.js +0 -2
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import http2 from 'http';
|
|
2
|
+
import fs2 from 'fs/promises';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
|
+
import fssync from 'fs';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import 'child_process';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import 'module';
|
|
11
|
+
|
|
12
|
+
// src/daemon/daemon.ts
|
|
13
|
+
(() => {
|
|
14
|
+
try {
|
|
15
|
+
const metaUrl = import.meta.url;
|
|
16
|
+
if (metaUrl && typeof metaUrl === "string" && metaUrl.startsWith("file://")) {
|
|
17
|
+
return path.dirname(fileURLToPath(metaUrl));
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
}
|
|
21
|
+
return process.cwd();
|
|
22
|
+
})();
|
|
23
|
+
function computeProjectId(projectRoot) {
|
|
24
|
+
const real = fssync.realpathSync(projectRoot);
|
|
25
|
+
return crypto.createHash("sha256").update(real).digest("hex").slice(0, 16);
|
|
26
|
+
}
|
|
27
|
+
function getCacheDir(override) {
|
|
28
|
+
if (override) {
|
|
29
|
+
return override;
|
|
30
|
+
}
|
|
31
|
+
const envCacheDir = process.env.MOCK_MCP_CACHE_DIR;
|
|
32
|
+
if (envCacheDir) {
|
|
33
|
+
return envCacheDir;
|
|
34
|
+
}
|
|
35
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
36
|
+
if (xdg) {
|
|
37
|
+
return xdg;
|
|
38
|
+
}
|
|
39
|
+
if (process.platform === "win32" && process.env.LOCALAPPDATA) {
|
|
40
|
+
return process.env.LOCALAPPDATA;
|
|
41
|
+
}
|
|
42
|
+
const home = os.homedir();
|
|
43
|
+
if (home) {
|
|
44
|
+
return path.join(home, ".cache");
|
|
45
|
+
}
|
|
46
|
+
return os.tmpdir();
|
|
47
|
+
}
|
|
48
|
+
function getPaths(projectId, cacheDir) {
|
|
49
|
+
const base = path.join(getCacheDir(cacheDir), "mock-mcp");
|
|
50
|
+
const registryPath = path.join(base, `${projectId}.json`);
|
|
51
|
+
const lockPath = path.join(base, `${projectId}.lock`);
|
|
52
|
+
const ipcPath = process.platform === "win32" ? `\\\\.\\pipe\\mock-mcp-${projectId}` : path.join(base, `${projectId}.sock`);
|
|
53
|
+
return { base, registryPath, lockPath, ipcPath };
|
|
54
|
+
}
|
|
55
|
+
async function writeRegistry(registryPath, registry) {
|
|
56
|
+
await fs2.writeFile(registryPath, JSON.stringify(registry, null, 2), {
|
|
57
|
+
encoding: "utf-8",
|
|
58
|
+
mode: 384
|
|
59
|
+
// Read/write for owner only
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/shared/protocol.ts
|
|
64
|
+
var HELLO_TEST = "HELLO_TEST";
|
|
65
|
+
var HELLO_ACK = "HELLO_ACK";
|
|
66
|
+
var BATCH_MOCK_REQUEST = "BATCH_MOCK_REQUEST";
|
|
67
|
+
var BATCH_MOCK_RESULT = "BATCH_MOCK_RESULT";
|
|
68
|
+
var HEARTBEAT = "HEARTBEAT";
|
|
69
|
+
var HEARTBEAT_ACK = "HEARTBEAT_ACK";
|
|
70
|
+
var RPC_GET_STATUS = "getStatus";
|
|
71
|
+
var RPC_LIST_RUNS = "listRuns";
|
|
72
|
+
var RPC_CLAIM_NEXT_BATCH = "claimNextBatch";
|
|
73
|
+
var RPC_PROVIDE_BATCH = "provideBatch";
|
|
74
|
+
var RPC_RELEASE_BATCH = "releaseBatch";
|
|
75
|
+
var RPC_GET_BATCH = "getBatch";
|
|
76
|
+
var RPC_ERROR_METHOD_NOT_FOUND = -32601;
|
|
77
|
+
var RPC_ERROR_INTERNAL = -32603;
|
|
78
|
+
var RPC_ERROR_NOT_FOUND = -32e3;
|
|
79
|
+
var RPC_ERROR_UNAUTHORIZED = -32001;
|
|
80
|
+
var RPC_ERROR_CONFLICT = -32002;
|
|
81
|
+
var RPC_ERROR_EXPIRED = -32003;
|
|
82
|
+
function isHelloTestMessage(msg) {
|
|
83
|
+
if (!msg || typeof msg !== "object") return false;
|
|
84
|
+
const m = msg;
|
|
85
|
+
return m.type === HELLO_TEST && typeof m.token === "string" && typeof m.runId === "string" && typeof m.pid === "number" && typeof m.cwd === "string";
|
|
86
|
+
}
|
|
87
|
+
function isBatchMockRequestMessage(msg) {
|
|
88
|
+
if (!msg || typeof msg !== "object") return false;
|
|
89
|
+
const m = msg;
|
|
90
|
+
return m.type === BATCH_MOCK_REQUEST && typeof m.runId === "string" && Array.isArray(m.requests);
|
|
91
|
+
}
|
|
92
|
+
function isHeartbeatMessage(msg) {
|
|
93
|
+
if (!msg || typeof msg !== "object") return false;
|
|
94
|
+
const m = msg;
|
|
95
|
+
return m.type === HEARTBEAT && typeof m.runId === "string";
|
|
96
|
+
}
|
|
97
|
+
function isJsonRpcRequest(msg) {
|
|
98
|
+
if (!msg || typeof msg !== "object") return false;
|
|
99
|
+
const m = msg;
|
|
100
|
+
return m.jsonrpc === "2.0" && (typeof m.id === "string" || typeof m.id === "number") && typeof m.method === "string";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/daemon/daemon.ts
|
|
104
|
+
var MockMcpDaemon = class {
|
|
105
|
+
logger;
|
|
106
|
+
opts;
|
|
107
|
+
server;
|
|
108
|
+
wss;
|
|
109
|
+
sweepTimer;
|
|
110
|
+
idleTimer;
|
|
111
|
+
startedAt;
|
|
112
|
+
// State management
|
|
113
|
+
runs = /* @__PURE__ */ new Map();
|
|
114
|
+
batches = /* @__PURE__ */ new Map();
|
|
115
|
+
pendingQueue = [];
|
|
116
|
+
// batchIds in order
|
|
117
|
+
batchSeq = 0;
|
|
118
|
+
constructor(options) {
|
|
119
|
+
this.logger = options.logger ?? console;
|
|
120
|
+
this.opts = {
|
|
121
|
+
projectRoot: options.projectRoot,
|
|
122
|
+
token: options.token,
|
|
123
|
+
version: options.version,
|
|
124
|
+
cacheDir: options.cacheDir,
|
|
125
|
+
logger: this.logger,
|
|
126
|
+
defaultLeaseMs: options.defaultLeaseMs ?? 3e4,
|
|
127
|
+
sweepIntervalMs: options.sweepIntervalMs ?? 5e3,
|
|
128
|
+
idleShutdownMs: options.idleShutdownMs ?? 6e5
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// ===========================================================================
|
|
132
|
+
// Lifecycle
|
|
133
|
+
// ===========================================================================
|
|
134
|
+
async start() {
|
|
135
|
+
const projectId = computeProjectId(this.opts.projectRoot);
|
|
136
|
+
const { base, registryPath, ipcPath } = getPaths(projectId, this.opts.cacheDir);
|
|
137
|
+
await fs2.mkdir(base, { recursive: true });
|
|
138
|
+
if (process.platform !== "win32") {
|
|
139
|
+
try {
|
|
140
|
+
await fs2.rm(ipcPath);
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const server = http2.createServer((req, res) => this.handleHttp(req, res));
|
|
145
|
+
this.server = server;
|
|
146
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
147
|
+
this.wss = wss;
|
|
148
|
+
server.on("upgrade", (req, socket, head) => {
|
|
149
|
+
if (!req.url?.startsWith("/test")) {
|
|
150
|
+
socket.destroy();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
154
|
+
wss.emit("connection", ws, req);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
wss.on("connection", (ws, req) => this.handleWsConnection(ws, req));
|
|
158
|
+
await new Promise((resolve, reject) => {
|
|
159
|
+
server.once("error", reject);
|
|
160
|
+
server.listen(ipcPath, () => {
|
|
161
|
+
this.logger.error(`\u{1F680} Daemon listening on ${ipcPath}`);
|
|
162
|
+
resolve();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
this.startedAt = Date.now();
|
|
166
|
+
const registry = {
|
|
167
|
+
projectId,
|
|
168
|
+
projectRoot: this.opts.projectRoot,
|
|
169
|
+
ipcPath,
|
|
170
|
+
token: this.opts.token,
|
|
171
|
+
pid: process.pid,
|
|
172
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
173
|
+
version: this.opts.version
|
|
174
|
+
};
|
|
175
|
+
await writeRegistry(registryPath, registry);
|
|
176
|
+
this.sweepTimer = setInterval(() => this.sweepExpiredClaims(), this.opts.sweepIntervalMs);
|
|
177
|
+
this.sweepTimer.unref?.();
|
|
178
|
+
this.resetIdleTimer();
|
|
179
|
+
this.logger.error(`\u2705 Daemon ready (project: ${projectId}, pid: ${process.pid})`);
|
|
180
|
+
}
|
|
181
|
+
async stop() {
|
|
182
|
+
if (this.sweepTimer) {
|
|
183
|
+
clearInterval(this.sweepTimer);
|
|
184
|
+
this.sweepTimer = void 0;
|
|
185
|
+
}
|
|
186
|
+
if (this.idleTimer) {
|
|
187
|
+
clearTimeout(this.idleTimer);
|
|
188
|
+
this.idleTimer = void 0;
|
|
189
|
+
}
|
|
190
|
+
for (const run of this.runs.values()) {
|
|
191
|
+
run.ws.close(1001, "Daemon shutting down");
|
|
192
|
+
}
|
|
193
|
+
this.runs.clear();
|
|
194
|
+
this.wss?.close();
|
|
195
|
+
await new Promise((resolve) => {
|
|
196
|
+
if (!this.server) {
|
|
197
|
+
resolve();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
this.server.close(() => resolve());
|
|
201
|
+
});
|
|
202
|
+
this.batches.clear();
|
|
203
|
+
this.pendingQueue.length = 0;
|
|
204
|
+
this.logger.error("\u{1F44B} Daemon stopped");
|
|
205
|
+
}
|
|
206
|
+
// ===========================================================================
|
|
207
|
+
// HTTP Handler (/health, /control)
|
|
208
|
+
// ===========================================================================
|
|
209
|
+
async handleHttp(req, res) {
|
|
210
|
+
try {
|
|
211
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
212
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
213
|
+
res.end(
|
|
214
|
+
JSON.stringify({
|
|
215
|
+
ok: true,
|
|
216
|
+
pid: process.pid,
|
|
217
|
+
version: this.opts.version,
|
|
218
|
+
projectId: computeProjectId(this.opts.projectRoot)
|
|
219
|
+
})
|
|
220
|
+
);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (req.method === "POST" && req.url === "/control") {
|
|
224
|
+
const token = req.headers["x-mock-mcp-token"];
|
|
225
|
+
if (token !== this.opts.token) {
|
|
226
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
227
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const body = await this.readBody(req);
|
|
231
|
+
let rpcReq;
|
|
232
|
+
try {
|
|
233
|
+
rpcReq = JSON.parse(body);
|
|
234
|
+
} catch {
|
|
235
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
236
|
+
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (!isJsonRpcRequest(rpcReq)) {
|
|
240
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
241
|
+
res.end(JSON.stringify({ error: "Invalid JSON-RPC request" }));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const rpcRes = await this.handleRpc(rpcReq);
|
|
245
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
246
|
+
res.end(JSON.stringify(rpcRes));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
250
|
+
res.end("Not Found");
|
|
251
|
+
} catch (e) {
|
|
252
|
+
this.logger.error("HTTP handler error:", e);
|
|
253
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
254
|
+
res.end(e instanceof Error ? e.message : String(e));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
readBody(req) {
|
|
258
|
+
return new Promise((resolve, reject) => {
|
|
259
|
+
let buf = "";
|
|
260
|
+
req.on("data", (chunk) => buf += chunk);
|
|
261
|
+
req.on("end", () => resolve(buf));
|
|
262
|
+
req.on("error", reject);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
// ===========================================================================
|
|
266
|
+
// WebSocket Handler (/test)
|
|
267
|
+
// ===========================================================================
|
|
268
|
+
handleWsConnection(ws, _req) {
|
|
269
|
+
let authed = false;
|
|
270
|
+
let runId = null;
|
|
271
|
+
const helloTimeout = setTimeout(() => {
|
|
272
|
+
if (!authed) {
|
|
273
|
+
ws.close(1008, "HELLO timeout");
|
|
274
|
+
}
|
|
275
|
+
}, 5e3);
|
|
276
|
+
ws.on("message", (data) => {
|
|
277
|
+
let msg;
|
|
278
|
+
try {
|
|
279
|
+
msg = JSON.parse(data.toString());
|
|
280
|
+
} catch {
|
|
281
|
+
this.logger.warn("Invalid JSON from test process");
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (!authed) {
|
|
285
|
+
if (!isHelloTestMessage(msg)) {
|
|
286
|
+
ws.close(1008, "Expected HELLO_TEST");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (msg.token !== this.opts.token) {
|
|
290
|
+
ws.close(1008, "Invalid token");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
authed = true;
|
|
294
|
+
clearTimeout(helloTimeout);
|
|
295
|
+
runId = msg.runId;
|
|
296
|
+
const runState = {
|
|
297
|
+
runId,
|
|
298
|
+
pid: msg.pid,
|
|
299
|
+
cwd: msg.cwd,
|
|
300
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
301
|
+
lastSeen: Date.now(),
|
|
302
|
+
testMeta: msg.testMeta,
|
|
303
|
+
ws
|
|
304
|
+
};
|
|
305
|
+
this.runs.set(runId, runState);
|
|
306
|
+
const ack = { type: HELLO_ACK, runId };
|
|
307
|
+
ws.send(JSON.stringify(ack));
|
|
308
|
+
this.logger.error(`\u{1F50C} Test run connected: ${runId} (pid: ${msg.pid})`);
|
|
309
|
+
this.resetIdleTimer();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (isBatchMockRequestMessage(msg)) {
|
|
313
|
+
this.handleBatchRequest(runId, msg.requests, ws);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (isHeartbeatMessage(msg)) {
|
|
317
|
+
const run = this.runs.get(msg.runId);
|
|
318
|
+
if (run) {
|
|
319
|
+
run.lastSeen = Date.now();
|
|
320
|
+
}
|
|
321
|
+
const ack = { type: HEARTBEAT_ACK, runId: msg.runId };
|
|
322
|
+
ws.send(JSON.stringify(ack));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
ws.on("close", () => {
|
|
327
|
+
clearTimeout(helloTimeout);
|
|
328
|
+
if (runId) {
|
|
329
|
+
this.cleanupRun(runId);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
ws.on("error", (err) => {
|
|
333
|
+
this.logger.error("WebSocket error:", err);
|
|
334
|
+
if (runId) {
|
|
335
|
+
this.cleanupRun(runId);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
handleBatchRequest(runId, requests, ws) {
|
|
340
|
+
const batchId = `batch:${runId}:${++this.batchSeq}`;
|
|
341
|
+
const batch = {
|
|
342
|
+
batchId,
|
|
343
|
+
runId,
|
|
344
|
+
requests,
|
|
345
|
+
createdAt: Date.now(),
|
|
346
|
+
status: "pending"
|
|
347
|
+
};
|
|
348
|
+
this.batches.set(batchId, batch);
|
|
349
|
+
this.pendingQueue.push(batchId);
|
|
350
|
+
this.logger.error(
|
|
351
|
+
[
|
|
352
|
+
`\u{1F4E5} Received ${requests.length} request(s) (${batchId})`,
|
|
353
|
+
...requests.map(
|
|
354
|
+
(req, i) => ` ${i + 1}. ${req.method} ${req.endpoint} (${req.requestId})`
|
|
355
|
+
)
|
|
356
|
+
].join("\n")
|
|
357
|
+
);
|
|
358
|
+
this.logger.error("\u23F3 Awaiting mock data from MCP adapter...");
|
|
359
|
+
}
|
|
360
|
+
cleanupRun(runId) {
|
|
361
|
+
const run = this.runs.get(runId);
|
|
362
|
+
if (!run) return;
|
|
363
|
+
this.runs.delete(runId);
|
|
364
|
+
for (const [batchId, batch] of this.batches) {
|
|
365
|
+
if (batch.runId === runId) {
|
|
366
|
+
this.batches.delete(batchId);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
for (let i = this.pendingQueue.length - 1; i >= 0; i--) {
|
|
370
|
+
const bid = this.pendingQueue[i];
|
|
371
|
+
if (!this.batches.has(bid)) {
|
|
372
|
+
this.pendingQueue.splice(i, 1);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
this.logger.error(`\u{1F50C} Test run disconnected: ${runId}`);
|
|
376
|
+
this.resetIdleTimer();
|
|
377
|
+
}
|
|
378
|
+
// ===========================================================================
|
|
379
|
+
// JSON-RPC Handler
|
|
380
|
+
// ===========================================================================
|
|
381
|
+
async handleRpc(req) {
|
|
382
|
+
try {
|
|
383
|
+
this.sweepExpiredClaims();
|
|
384
|
+
const params = req.params ?? {};
|
|
385
|
+
switch (req.method) {
|
|
386
|
+
case RPC_GET_STATUS:
|
|
387
|
+
return this.rpcSuccess(req.id, this.getStatus());
|
|
388
|
+
case RPC_LIST_RUNS:
|
|
389
|
+
return this.rpcSuccess(req.id, this.listRuns());
|
|
390
|
+
case RPC_CLAIM_NEXT_BATCH:
|
|
391
|
+
return this.rpcSuccess(req.id, this.claimNextBatch(params));
|
|
392
|
+
case RPC_PROVIDE_BATCH:
|
|
393
|
+
return this.rpcSuccess(req.id, await this.provideBatch(params));
|
|
394
|
+
case RPC_RELEASE_BATCH:
|
|
395
|
+
return this.rpcSuccess(req.id, this.releaseBatch(params));
|
|
396
|
+
case RPC_GET_BATCH:
|
|
397
|
+
return this.rpcSuccess(req.id, this.getBatch(params));
|
|
398
|
+
default:
|
|
399
|
+
return this.rpcError(req.id, RPC_ERROR_METHOD_NOT_FOUND, `Unknown method: ${req.method}`);
|
|
400
|
+
}
|
|
401
|
+
} catch (e) {
|
|
402
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
403
|
+
const code = this.getErrorCode(e);
|
|
404
|
+
return this.rpcError(req.id, code, msg);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
getErrorCode(e) {
|
|
408
|
+
if (e instanceof RpcError) {
|
|
409
|
+
return e.code;
|
|
410
|
+
}
|
|
411
|
+
return RPC_ERROR_INTERNAL;
|
|
412
|
+
}
|
|
413
|
+
rpcSuccess(id, result) {
|
|
414
|
+
return { jsonrpc: "2.0", id, result };
|
|
415
|
+
}
|
|
416
|
+
rpcError(id, code, message) {
|
|
417
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
418
|
+
}
|
|
419
|
+
// ===========================================================================
|
|
420
|
+
// RPC Methods
|
|
421
|
+
// ===========================================================================
|
|
422
|
+
getStatus() {
|
|
423
|
+
const pending = this.pendingQueue.filter((bid) => {
|
|
424
|
+
const b = this.batches.get(bid);
|
|
425
|
+
return b && b.status === "pending";
|
|
426
|
+
}).length;
|
|
427
|
+
const claimed = Array.from(this.batches.values()).filter(
|
|
428
|
+
(b) => b.status === "claimed"
|
|
429
|
+
).length;
|
|
430
|
+
return {
|
|
431
|
+
version: this.opts.version,
|
|
432
|
+
projectId: computeProjectId(this.opts.projectRoot),
|
|
433
|
+
projectRoot: this.opts.projectRoot,
|
|
434
|
+
pid: process.pid,
|
|
435
|
+
uptime: this.startedAt ? Date.now() - this.startedAt : 0,
|
|
436
|
+
runs: this.runs.size,
|
|
437
|
+
pending,
|
|
438
|
+
claimed,
|
|
439
|
+
totalBatches: this.batches.size
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
listRuns() {
|
|
443
|
+
const runs = Array.from(this.runs.values()).map((r) => {
|
|
444
|
+
const pendingBatches = Array.from(this.batches.values()).filter(
|
|
445
|
+
(b) => b.runId === r.runId && b.status === "pending"
|
|
446
|
+
).length;
|
|
447
|
+
return {
|
|
448
|
+
runId: r.runId,
|
|
449
|
+
pid: r.pid,
|
|
450
|
+
cwd: r.cwd,
|
|
451
|
+
startedAt: r.startedAt,
|
|
452
|
+
lastSeen: r.lastSeen,
|
|
453
|
+
pendingBatches,
|
|
454
|
+
testMeta: r.testMeta
|
|
455
|
+
};
|
|
456
|
+
});
|
|
457
|
+
return { runs };
|
|
458
|
+
}
|
|
459
|
+
claimNextBatch(params) {
|
|
460
|
+
const { adapterId, runId, leaseMs = this.opts.defaultLeaseMs } = params;
|
|
461
|
+
if (!adapterId) {
|
|
462
|
+
throw new RpcError(RPC_ERROR_UNAUTHORIZED, "adapterId required");
|
|
463
|
+
}
|
|
464
|
+
for (let i = 0; i < this.pendingQueue.length; i++) {
|
|
465
|
+
const batchId = this.pendingQueue[i];
|
|
466
|
+
const batch = this.batches.get(batchId);
|
|
467
|
+
if (!batch || batch.status !== "pending") {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (runId && batch.runId !== runId) {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
this.pendingQueue.splice(i, 1);
|
|
474
|
+
batch.status = "claimed";
|
|
475
|
+
batch.claim = {
|
|
476
|
+
adapterId,
|
|
477
|
+
claimToken: crypto.randomUUID(),
|
|
478
|
+
leaseUntil: Date.now() + leaseMs
|
|
479
|
+
};
|
|
480
|
+
this.logger.error(`\u{1F512} Batch ${batchId} claimed by adapter ${adapterId.slice(0, 8)}...`);
|
|
481
|
+
return {
|
|
482
|
+
batchId: batch.batchId,
|
|
483
|
+
runId: batch.runId,
|
|
484
|
+
requests: batch.requests,
|
|
485
|
+
claimToken: batch.claim.claimToken,
|
|
486
|
+
leaseUntil: batch.claim.leaseUntil
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
async provideBatch(params) {
|
|
492
|
+
const { adapterId, batchId, claimToken, mocks } = params;
|
|
493
|
+
const batch = this.batches.get(batchId);
|
|
494
|
+
if (!batch) {
|
|
495
|
+
throw new RpcError(RPC_ERROR_NOT_FOUND, `Batch not found: ${batchId}`);
|
|
496
|
+
}
|
|
497
|
+
if (batch.status !== "claimed" || !batch.claim) {
|
|
498
|
+
throw new RpcError(RPC_ERROR_CONFLICT, `Batch not in claimed state: ${batchId}`);
|
|
499
|
+
}
|
|
500
|
+
if (batch.claim.adapterId !== adapterId) {
|
|
501
|
+
throw new RpcError(RPC_ERROR_UNAUTHORIZED, "Not the owner of this batch");
|
|
502
|
+
}
|
|
503
|
+
if (batch.claim.claimToken !== claimToken) {
|
|
504
|
+
throw new RpcError(RPC_ERROR_UNAUTHORIZED, "Invalid claim token");
|
|
505
|
+
}
|
|
506
|
+
if (batch.claim.leaseUntil <= Date.now()) {
|
|
507
|
+
batch.status = "pending";
|
|
508
|
+
batch.claim = void 0;
|
|
509
|
+
this.pendingQueue.push(batchId);
|
|
510
|
+
throw new RpcError(RPC_ERROR_EXPIRED, "Claim lease expired");
|
|
511
|
+
}
|
|
512
|
+
this.validateMocks(batch, mocks);
|
|
513
|
+
const run = this.runs.get(batch.runId);
|
|
514
|
+
if (!run) {
|
|
515
|
+
this.batches.delete(batchId);
|
|
516
|
+
throw new RpcError(RPC_ERROR_NOT_FOUND, "Run is gone");
|
|
517
|
+
}
|
|
518
|
+
if (run.ws.readyState !== WebSocket.OPEN) {
|
|
519
|
+
this.batches.delete(batchId);
|
|
520
|
+
throw new RpcError(RPC_ERROR_NOT_FOUND, "Test process disconnected");
|
|
521
|
+
}
|
|
522
|
+
const result = {
|
|
523
|
+
type: BATCH_MOCK_RESULT,
|
|
524
|
+
batchId,
|
|
525
|
+
mocks
|
|
526
|
+
};
|
|
527
|
+
run.ws.send(JSON.stringify(result));
|
|
528
|
+
batch.status = "fulfilled";
|
|
529
|
+
this.batches.delete(batchId);
|
|
530
|
+
this.logger.error(`\u2705 Delivered ${mocks.length} mock(s) for ${batchId}`);
|
|
531
|
+
return { ok: true, message: `Provided ${mocks.length} mock(s) for ${batchId}` };
|
|
532
|
+
}
|
|
533
|
+
validateMocks(batch, mocks) {
|
|
534
|
+
const expectedIds = new Set(batch.requests.map((r) => r.requestId));
|
|
535
|
+
const providedIds = /* @__PURE__ */ new Set();
|
|
536
|
+
for (const mock of mocks) {
|
|
537
|
+
if (!expectedIds.has(mock.requestId)) {
|
|
538
|
+
throw new RpcError(
|
|
539
|
+
RPC_ERROR_CONFLICT,
|
|
540
|
+
`Mock references unknown requestId: ${mock.requestId}`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (providedIds.has(mock.requestId)) {
|
|
544
|
+
throw new RpcError(
|
|
545
|
+
RPC_ERROR_CONFLICT,
|
|
546
|
+
`Duplicate mock for requestId: ${mock.requestId}`
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
providedIds.add(mock.requestId);
|
|
550
|
+
}
|
|
551
|
+
const missing = Array.from(expectedIds).filter((id) => !providedIds.has(id));
|
|
552
|
+
if (missing.length > 0) {
|
|
553
|
+
throw new RpcError(
|
|
554
|
+
RPC_ERROR_CONFLICT,
|
|
555
|
+
`Missing mocks for requestId(s): ${missing.join(", ")}`
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
releaseBatch(params) {
|
|
560
|
+
const { adapterId, batchId, claimToken } = params;
|
|
561
|
+
const batch = this.batches.get(batchId);
|
|
562
|
+
if (!batch) {
|
|
563
|
+
throw new RpcError(RPC_ERROR_NOT_FOUND, `Batch not found: ${batchId}`);
|
|
564
|
+
}
|
|
565
|
+
if (batch.status !== "claimed" || !batch.claim) {
|
|
566
|
+
throw new RpcError(RPC_ERROR_CONFLICT, `Batch not in claimed state: ${batchId}`);
|
|
567
|
+
}
|
|
568
|
+
if (batch.claim.adapterId !== adapterId || batch.claim.claimToken !== claimToken) {
|
|
569
|
+
throw new RpcError(RPC_ERROR_UNAUTHORIZED, "Not the owner of this batch");
|
|
570
|
+
}
|
|
571
|
+
batch.status = "pending";
|
|
572
|
+
batch.claim = void 0;
|
|
573
|
+
this.pendingQueue.push(batchId);
|
|
574
|
+
this.logger.error(`\u{1F513} Batch ${batchId} released by adapter`);
|
|
575
|
+
return { ok: true };
|
|
576
|
+
}
|
|
577
|
+
getBatch(params) {
|
|
578
|
+
const batch = this.batches.get(params.batchId);
|
|
579
|
+
if (!batch) {
|
|
580
|
+
throw new RpcError(RPC_ERROR_NOT_FOUND, `Batch not found: ${params.batchId}`);
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
batchId: batch.batchId,
|
|
584
|
+
runId: batch.runId,
|
|
585
|
+
requests: batch.requests,
|
|
586
|
+
status: batch.status,
|
|
587
|
+
createdAt: batch.createdAt,
|
|
588
|
+
claim: batch.claim ? { adapterId: batch.claim.adapterId, leaseUntil: batch.claim.leaseUntil } : void 0
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
// ===========================================================================
|
|
592
|
+
// Maintenance
|
|
593
|
+
// ===========================================================================
|
|
594
|
+
sweepExpiredClaims() {
|
|
595
|
+
const now = Date.now();
|
|
596
|
+
for (const batch of this.batches.values()) {
|
|
597
|
+
if (batch.status === "claimed" && batch.claim && batch.claim.leaseUntil <= now) {
|
|
598
|
+
this.logger.warn(`\u23F0 Claim expired for ${batch.batchId}, returning to pending`);
|
|
599
|
+
batch.status = "pending";
|
|
600
|
+
batch.claim = void 0;
|
|
601
|
+
this.pendingQueue.push(batch.batchId);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
resetIdleTimer() {
|
|
606
|
+
if (this.idleTimer) {
|
|
607
|
+
clearTimeout(this.idleTimer);
|
|
608
|
+
}
|
|
609
|
+
if (this.runs.size === 0) {
|
|
610
|
+
this.idleTimer = setTimeout(() => {
|
|
611
|
+
if (this.runs.size === 0) {
|
|
612
|
+
this.logger.error("\u{1F4A4} No activity, shutting down daemon...");
|
|
613
|
+
this.stop().then(() => process.exit(0));
|
|
614
|
+
}
|
|
615
|
+
}, this.opts.idleShutdownMs);
|
|
616
|
+
this.idleTimer.unref?.();
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
var RpcError = class extends Error {
|
|
621
|
+
constructor(code, message) {
|
|
622
|
+
super(message);
|
|
623
|
+
this.code = code;
|
|
624
|
+
this.name = "RpcError";
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
export { MockMcpDaemon };
|