multi-agent-collaboration-mcp 0.13.0 → 0.16.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 +49 -281
- package/dist/bounded-lines.js +1 -1
- package/dist/build-info.json +1 -1
- package/dist/check.js +131 -44
- package/dist/db.js +935 -583
- package/dist/index.js +571 -575
- package/dist/poller.js +179 -78
- package/package.json +3 -2
- package/scripts/connection-probe.mjs +220 -0
- package/web/index.html +783 -256
- package/web/server.mjs +377 -159
package/dist/poller.js
CHANGED
|
@@ -9,8 +9,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
9
9
|
import { join, resolve } from "node:path";
|
|
10
10
|
const USAGE = `agent-chat-poller: wait for unread work with one SQLite probe every five seconds.
|
|
11
11
|
Usage:
|
|
12
|
-
poller.js --agent <id> [--room <id|name>] [--
|
|
13
|
-
[--owner-pid <pid>]
|
|
12
|
+
poller.js --agent <id> [--room <id|name>] [--owner-pid <pid>]
|
|
14
13
|
[--mentions-only] [--interval <seconds>] [--timeout <seconds>]
|
|
15
14
|
[--ok-on-timeout]
|
|
16
15
|
|
|
@@ -18,13 +17,24 @@ The interval must be 5..3600 seconds (default 5). The timeout must be
|
|
|
18
17
|
1..86400 seconds (default 1200). Exit 0 = work exists; with --ok-on-timeout,
|
|
19
18
|
exit 0 also reports a quiet deadline as has_updates:false. Without it,
|
|
20
19
|
timeout exits 124. Exit 2 = invalid arguments, duplicate watcher, DB error, or
|
|
21
|
-
a
|
|
20
|
+
a terminal watcher state. Inspect stderr before re-arming:
|
|
21
|
+
retired_identity persona is terminally retired; it can never watch again
|
|
22
|
+
left_room persona left the scoped room
|
|
23
|
+
left_all_rooms persona left every joined room
|
|
24
|
+
no_room_memberships persona has no remaining room memberships
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
retired_identity is the one state no new command can fix. The others describe a
|
|
27
|
+
persona that still exists and can rejoin; a retired persona cannot, so it takes
|
|
28
|
+
precedence over every membership state. A retired persona keeps its history and
|
|
29
|
+
its non-advancing reads still work; what it can never do again is post, advance
|
|
30
|
+
a read marker, claim, join, or be watched.
|
|
31
|
+
|
|
32
|
+
There is no tenure argument. An agent_id is allocated once and never deleted,
|
|
33
|
+
reinserted, revived, or rebound, so it names one identity for the life of the
|
|
34
|
+
database; every probe re-reads that identity's live state, which is the whole
|
|
35
|
+
fence. --owner-pid alone marks a GENERATED watcher owned by an MCP process and
|
|
36
|
+
enables its guarded liveness heartbeat; without it the watcher is strictly
|
|
37
|
+
read-only.
|
|
28
38
|
`;
|
|
29
39
|
function argumentError(message) {
|
|
30
40
|
throw new Error(message);
|
|
@@ -41,7 +51,6 @@ function parseInteger(value, flag, min, max) {
|
|
|
41
51
|
function parseArgs(argv) {
|
|
42
52
|
let agent;
|
|
43
53
|
let room;
|
|
44
|
-
let epoch;
|
|
45
54
|
let ownerPid;
|
|
46
55
|
let db;
|
|
47
56
|
let mentionsOnly = false;
|
|
@@ -67,9 +76,6 @@ function parseArgs(argv) {
|
|
|
67
76
|
agent = take();
|
|
68
77
|
else if (flag === "--room")
|
|
69
78
|
room = take();
|
|
70
|
-
else if (flag === "--epoch") {
|
|
71
|
-
epoch = parseInteger(take(), flag, 1, Number.MAX_SAFE_INTEGER);
|
|
72
|
-
}
|
|
73
79
|
else if (flag === "--owner-pid") {
|
|
74
80
|
ownerPid = parseInteger(take(), flag, 1, 2_147_483_647);
|
|
75
81
|
}
|
|
@@ -111,7 +117,6 @@ function parseArgs(argv) {
|
|
|
111
117
|
return {
|
|
112
118
|
agent,
|
|
113
119
|
room,
|
|
114
|
-
epoch,
|
|
115
120
|
ownerPid,
|
|
116
121
|
db,
|
|
117
122
|
mentionsOnly,
|
|
@@ -165,11 +170,11 @@ function acquireLock(path, token) {
|
|
|
165
170
|
}
|
|
166
171
|
catch { }
|
|
167
172
|
if (processIsAlive(owner)) {
|
|
168
|
-
argumentError(`
|
|
173
|
+
argumentError(`watcher lock references live pid ${owner}; refusing a duplicate (lock: ${path})`);
|
|
169
174
|
}
|
|
170
175
|
// Fail closed. Automatic stale-lock stealing needs a second interprocess
|
|
171
176
|
// lock to avoid two reapers deleting each other's replacement.
|
|
172
|
-
argumentError(`stale watcher lock
|
|
177
|
+
argumentError(`stale watcher lock file: ${path}; remove only this exact file, then retry`);
|
|
173
178
|
}
|
|
174
179
|
}
|
|
175
180
|
const sleep = (milliseconds) => new Promise((done) => setTimeout(done, milliseconds));
|
|
@@ -245,12 +250,12 @@ try {
|
|
|
245
250
|
database = new Database(path);
|
|
246
251
|
database.pragma("busy_timeout = 2000");
|
|
247
252
|
// A GENERATED runtime watcher -- one this server's wait_for_messages handed
|
|
248
|
-
// out, identified by carrying
|
|
253
|
+
// out, identified by carrying --owner-pid -- refreshes its
|
|
249
254
|
// persona's last_seen so an armed seat does not read as offline while the
|
|
250
255
|
// model is between turns. Nothing else the watcher does may write, and a
|
|
251
|
-
// watcher without
|
|
256
|
+
// watcher without it (a hand-run diagnostic watch) stays strictly
|
|
252
257
|
// read-only.
|
|
253
|
-
const heartbeatEnabled = args.ownerPid !== undefined
|
|
258
|
+
const heartbeatEnabled = args.ownerPid !== undefined;
|
|
254
259
|
if (!heartbeatEnabled)
|
|
255
260
|
database.pragma("query_only = ON");
|
|
256
261
|
let resolvedRoom;
|
|
@@ -280,17 +285,16 @@ try {
|
|
|
280
285
|
if (uid !== null && lockStat.uid !== uid) {
|
|
281
286
|
argumentError(`watcher lock directory is owned by another user: ${lockDir}`);
|
|
282
287
|
}
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
// interval at exactly the moment it most needed to hear.
|
|
288
|
+
// Owned and diagnostic watches have different functions: the owned watcher
|
|
289
|
+
// maintains liveness while the diagnostic watcher is read-only. Keep their
|
|
290
|
+
// locks separate so a stray diagnostic cannot block the generated wake
|
|
291
|
+
// channel and heartbeat. The numeric owner PID is not part of the identity;
|
|
292
|
+
// only the watcher class matters.
|
|
289
293
|
const scopeKey = JSON.stringify([
|
|
290
294
|
path,
|
|
291
295
|
args.agent,
|
|
292
296
|
resolvedRoom?.id ?? null,
|
|
293
|
-
args.
|
|
297
|
+
args.ownerPid === undefined ? "diagnostic" : "owned",
|
|
294
298
|
args.mentionsOnly,
|
|
295
299
|
]);
|
|
296
300
|
const lockName = createHash("sha256").update(scopeKey).digest("hex") + ".lock";
|
|
@@ -301,28 +305,84 @@ try {
|
|
|
301
305
|
acquireLock(lockPath, lockToken);
|
|
302
306
|
const params = { agent: args.agent };
|
|
303
307
|
/**
|
|
304
|
-
* A
|
|
308
|
+
* A terminally retired identity can never watch again.
|
|
305
309
|
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
* the
|
|
310
|
+
* Checked unconditionally on every watcher, generated or hand-run. This is
|
|
311
|
+
* the complete identity fence: an agent_id has one tenure, so "is this
|
|
312
|
+
* identity finished" is the only question, and a watcher for a finished one
|
|
313
|
+
* must exit rather than report traffic to a seat that cannot act on it or
|
|
314
|
+
* advance its marker over it.
|
|
315
|
+
*
|
|
316
|
+
* It also takes precedence over every membership state. "left the room" and
|
|
317
|
+
* "no memberships" describe a persona that can rejoin; a retired one cannot,
|
|
318
|
+
* and telling a caller to rejoin would be advice that cannot be followed.
|
|
313
319
|
*/
|
|
314
|
-
const
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
320
|
+
const checkRetired = (retiredAt) => {
|
|
321
|
+
if (retiredAt !== null) {
|
|
322
|
+
argumentError(`retired_identity: persona "${args.agent}" is terminally retired. No ` +
|
|
323
|
+
`watcher can represent it, and it can never post, advance a read ` +
|
|
324
|
+
`marker, claim, or join again; its history stands and non-advancing ` +
|
|
325
|
+
`reads of it still work. No new poller command will work for it. ` +
|
|
326
|
+
`Identify a live persona and arm a watcher from that.`);
|
|
319
327
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
328
|
+
};
|
|
329
|
+
/**
|
|
330
|
+
* A VANISHED persona stops every watcher, generated or hand-run. Looping
|
|
331
|
+
* quietly against a deleted row is the one outcome no caller can interpret,
|
|
332
|
+
* so it is a terminal exit rather than a quiet continue.
|
|
333
|
+
*/
|
|
334
|
+
const checkExists = (exists) => {
|
|
335
|
+
if (!exists) {
|
|
336
|
+
argumentError(`persona "${args.agent}" no longer exists; this watcher has nothing to watch`);
|
|
324
337
|
}
|
|
325
338
|
};
|
|
339
|
+
// ARM-TIME identity gate, before ANY membership state is consulted, for both
|
|
340
|
+
// the scoped and all-room paths.
|
|
341
|
+
//
|
|
342
|
+
// Order is the point: identity outranks membership. A retired persona must
|
|
343
|
+
// report retired_identity rather than left_room / left_all_rooms /
|
|
344
|
+
// no_room_memberships, all of which imply a recoverable situation.
|
|
345
|
+
//
|
|
346
|
+
// Read identity and membership from one snapshot.
|
|
347
|
+
//
|
|
348
|
+
// Read as separate autocommit statements these disagree, and the disagreement
|
|
349
|
+
// is exactly the ordering this gate exists to enforce: retirement updates
|
|
350
|
+
// agents.retired_at and soft-leaves memberships in ONE immediate transaction,
|
|
351
|
+
// so a retirement committing between the two reads leaves identity looking
|
|
352
|
+
// live and membership looking left -- and startup then reports left_room or
|
|
353
|
+
// left_all_rooms for a persona that is actually retired. A deferred read
|
|
354
|
+
// transaction makes both reads see the same snapshot, so they cannot describe
|
|
355
|
+
// two different moments. It covers startup only and is not held into the
|
|
356
|
+
// probe loop.
|
|
357
|
+
// Bound to a const first: `database` is a mutable module-level binding, so
|
|
358
|
+
// TypeScript cannot keep its non-null narrowing inside a closure.
|
|
359
|
+
const conn = database;
|
|
360
|
+
const startup = conn
|
|
361
|
+
.transaction(() => {
|
|
362
|
+
const persona = conn
|
|
363
|
+
.prepare("SELECT retired_at FROM agents WHERE id = ?")
|
|
364
|
+
.get(args.agent);
|
|
365
|
+
const membership = resolvedRoom
|
|
366
|
+
? conn
|
|
367
|
+
.prepare("SELECT left_at FROM memberships WHERE room_id = ? AND agent_id = ?")
|
|
368
|
+
.get(resolvedRoom.id, args.agent)
|
|
369
|
+
: undefined;
|
|
370
|
+
const counts = resolvedRoom
|
|
371
|
+
? undefined
|
|
372
|
+
: conn
|
|
373
|
+
.prepare(`SELECT COUNT(*) AS total,
|
|
374
|
+
COALESCE(SUM(CASE WHEN left_at IS NULL THEN 1 ELSE 0 END), 0)
|
|
375
|
+
AS present
|
|
376
|
+
FROM memberships WHERE agent_id = ?`)
|
|
377
|
+
.get(args.agent);
|
|
378
|
+
return { persona, membership, counts };
|
|
379
|
+
})
|
|
380
|
+
.deferred();
|
|
381
|
+
const armPersona = startup.persona;
|
|
382
|
+
if (!armPersona) {
|
|
383
|
+
argumentError(`no persona "${args.agent}"; identify a persona and arm a watcher from it`);
|
|
384
|
+
}
|
|
385
|
+
checkRetired(armPersona.retired_at);
|
|
326
386
|
const directed = args.mentionsOnly
|
|
327
387
|
? ` AND (g.mentions IS NOT NULL OR g.reply_to_agent IS NOT NULL)
|
|
328
388
|
AND (EXISTS (SELECT 1 FROM json_each(g.mentions) WHERE value = @agent)
|
|
@@ -330,9 +390,8 @@ try {
|
|
|
330
390
|
: "";
|
|
331
391
|
let probe;
|
|
332
392
|
if (resolvedRoom) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
.get(resolvedRoom.id, args.agent);
|
|
393
|
+
// From the startup snapshot, not a fresh read: see the transaction above.
|
|
394
|
+
const membership = startup.membership;
|
|
336
395
|
if (!membership) {
|
|
337
396
|
argumentError(`agent "${args.agent}" has not joined room ${resolvedRoom.id}`);
|
|
338
397
|
}
|
|
@@ -343,12 +402,20 @@ try {
|
|
|
343
402
|
`poller command it returns.`);
|
|
344
403
|
}
|
|
345
404
|
params.room_id = resolvedRoom.id;
|
|
346
|
-
// has_updates,
|
|
347
|
-
//
|
|
348
|
-
//
|
|
405
|
+
// has_updates, retired_at and left_at are all COLUMNS of a row this query
|
|
406
|
+
// always returns while the room and membership exist, so "no row" keeps
|
|
407
|
+
// its single unambiguous meaning.
|
|
408
|
+
//
|
|
409
|
+
// The agents row arrives through a LEFT JOIN rather than two scalar
|
|
410
|
+
// subqueries. Measured, not assumed: EXPLAIN QUERY PLAN on the subquery
|
|
411
|
+
// form showed TWO `SEARCH a USING INDEX sqlite_autoindex_agents_1 (id=?)`
|
|
412
|
+
// entries, one per subquery; the LEFT JOIN form shows one. LEFT, not inner,
|
|
413
|
+
// so a vanished persona cannot turn this into "no row" -- that phrase has
|
|
414
|
+
// to keep meaning the room or membership is gone, and a null agents_id
|
|
415
|
+
// is how a vanished persona reports itself.
|
|
349
416
|
const statement = database.prepare(`SELECT r.name AS room_name,
|
|
350
|
-
|
|
351
|
-
|
|
417
|
+
a.id AS agents_id,
|
|
418
|
+
a.retired_at AS retired_at,
|
|
352
419
|
mb.left_at AS left_at,
|
|
353
420
|
EXISTS (
|
|
354
421
|
SELECT 1 FROM messages g
|
|
@@ -359,22 +426,25 @@ try {
|
|
|
359
426
|
) AS has_updates
|
|
360
427
|
FROM rooms r
|
|
361
428
|
JOIN memberships mb ON mb.room_id = r.id AND mb.agent_id = @agent
|
|
429
|
+
LEFT JOIN agents a ON a.id = @agent
|
|
362
430
|
WHERE r.id = @room_id`);
|
|
363
431
|
probe = () => {
|
|
364
432
|
const row = statement.get(params);
|
|
365
433
|
if (!row) {
|
|
366
434
|
argumentError(`room ${resolvedRoom.id} was deleted or the membership disappeared while watching`);
|
|
367
435
|
}
|
|
368
|
-
|
|
436
|
+
// Identity FIRST: existence, then retirement, then membership.
|
|
437
|
+
checkExists(row.agents_id !== null);
|
|
438
|
+
checkRetired(row.retired_at);
|
|
369
439
|
// Checked on EVERY probe, not just at arm time. A watcher armed while
|
|
370
440
|
// present and left afterwards kept firing invisibly: peers correctly saw
|
|
371
441
|
// the persona absent while its watcher still delivered. left_at rides as
|
|
372
|
-
// returned DATA for the same reason
|
|
442
|
+
// returned DATA for the same reason retirement does -- as a WHERE
|
|
373
443
|
// predicate it would produce "no row", which already means the room or
|
|
374
444
|
// membership is gone, and the two need different diagnostics.
|
|
375
445
|
//
|
|
376
|
-
// This is a DIFFERENT exit from
|
|
377
|
-
// this command, but one means
|
|
446
|
+
// This is a DIFFERENT exit from retired_identity. Both say do not
|
|
447
|
+
// re-arm this command, but one means the identity is finished and the
|
|
378
448
|
// other means this persona is not in the room; the next agent should not
|
|
379
449
|
// have to guess which.
|
|
380
450
|
if (row.left_at !== null) {
|
|
@@ -389,18 +459,28 @@ try {
|
|
|
389
459
|
};
|
|
390
460
|
}
|
|
391
461
|
else {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
//
|
|
403
|
-
|
|
462
|
+
// From the startup snapshot, not a fresh read: see the transaction above.
|
|
463
|
+
const membershipCounts = startup.counts;
|
|
464
|
+
if (membershipCounts.total === 0) {
|
|
465
|
+
argumentError(`no_room_memberships: agent "${args.agent}" has no room memberships; ` +
|
|
466
|
+
"join a room and generate a fresh poller command");
|
|
467
|
+
}
|
|
468
|
+
if (membershipCounts.present === 0) {
|
|
469
|
+
argumentError(`left_all_rooms: agent "${args.agent}" has left every joined room; ` +
|
|
470
|
+
"rejoin one with join_room and use the new poller command");
|
|
471
|
+
}
|
|
472
|
+
// Anchor on the persona so every quiet probe still returns retirement and
|
|
473
|
+
// membership state. A vanished persona yields no row.
|
|
474
|
+
const statement = database.prepare(`SELECT a.retired_at AS retired_at,
|
|
475
|
+
EXISTS (
|
|
476
|
+
SELECT 1 FROM memberships all_mb
|
|
477
|
+
WHERE all_mb.agent_id = @agent
|
|
478
|
+
) AS has_membership,
|
|
479
|
+
EXISTS (
|
|
480
|
+
SELECT 1 FROM memberships present_mb
|
|
481
|
+
WHERE present_mb.agent_id = @agent
|
|
482
|
+
AND present_mb.left_at IS NULL
|
|
483
|
+
) AS has_present,
|
|
404
484
|
(SELECT mb.room_id FROM memberships mb
|
|
405
485
|
WHERE mb.agent_id = @agent AND mb.left_at IS NULL
|
|
406
486
|
AND EXISTS (
|
|
@@ -415,8 +495,22 @@ try {
|
|
|
415
495
|
const roomName = database.prepare("SELECT name FROM rooms WHERE id = ?");
|
|
416
496
|
probe = () => {
|
|
417
497
|
const row = statement.get(params);
|
|
418
|
-
|
|
419
|
-
|
|
498
|
+
// No row means the persona itself is gone; that is terminal, not quiet.
|
|
499
|
+
checkExists(!!row);
|
|
500
|
+
// Retirement outranks BOTH membership states below, so a retired persona
|
|
501
|
+
// never reports left_all_rooms or no_room_memberships -- both of which
|
|
502
|
+
// would tell the caller to rejoin, which it cannot do.
|
|
503
|
+
checkRetired(row.retired_at);
|
|
504
|
+
if (!row.has_membership) {
|
|
505
|
+
argumentError(`no_room_memberships: agent "${args.agent}" has no remaining room ` +
|
|
506
|
+
"memberships; join a room and generate a fresh poller command");
|
|
507
|
+
}
|
|
508
|
+
if (!row.has_present) {
|
|
509
|
+
argumentError(`left_all_rooms: agent "${args.agent}" left every joined room while ` +
|
|
510
|
+
"this watcher was armed; rejoin one with join_room and use the new " +
|
|
511
|
+
"poller command");
|
|
512
|
+
}
|
|
513
|
+
if (row.room_id === null)
|
|
420
514
|
return undefined;
|
|
421
515
|
// Name lookup only on a HIT, so the quiet path stays one statement.
|
|
422
516
|
const named = roomName.get(row.room_id);
|
|
@@ -428,12 +522,13 @@ try {
|
|
|
428
522
|
/**
|
|
429
523
|
* Liveness heartbeat for a generated runtime watcher.
|
|
430
524
|
*
|
|
431
|
-
* ONE prepared,
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
* cleared, so this cannot resurrect a seat
|
|
436
|
-
* swallowed: a watcher that cannot write must
|
|
525
|
+
* ONE prepared, fenced UPDATE. The EXISTS clause is the fence, and it tests
|
|
526
|
+
* retirement: a watcher whose identity is finished matches no row. Without
|
|
527
|
+
* it a retired seat could keep refreshing last_seen for up to one probe
|
|
528
|
+
* interval, advertising a live listener that can never act. It sets last_seen
|
|
529
|
+
* and nothing else: left_at is never cleared, so this cannot resurrect a seat
|
|
530
|
+
* that left the room. Failure is swallowed: a watcher that cannot write must
|
|
531
|
+
* still report traffic.
|
|
437
532
|
*
|
|
438
533
|
* Cost is one write per two minutes per armed watcher against five-second
|
|
439
534
|
* reads. Its contention impact is UNMEASURED.
|
|
@@ -450,7 +545,7 @@ try {
|
|
|
450
545
|
? database.prepare(`UPDATE memberships SET last_seen = datetime('now')
|
|
451
546
|
WHERE agent_id = @agent AND left_at IS NULL${heartbeatScope}
|
|
452
547
|
AND EXISTS (SELECT 1 FROM agents a
|
|
453
|
-
WHERE a.id = @agent AND a.
|
|
548
|
+
WHERE a.id = @agent AND a.retired_at IS NULL)`)
|
|
454
549
|
: null;
|
|
455
550
|
let lastHeartbeatMs = 0;
|
|
456
551
|
const heartbeat = () => {
|
|
@@ -466,13 +561,11 @@ try {
|
|
|
466
561
|
try {
|
|
467
562
|
heartbeatStatement.run({
|
|
468
563
|
agent: args.agent,
|
|
469
|
-
epoch: args.epoch,
|
|
470
564
|
...(resolvedRoom ? { room_id: resolvedRoom.id } : {}),
|
|
471
565
|
});
|
|
472
566
|
}
|
|
473
567
|
catch { }
|
|
474
568
|
};
|
|
475
|
-
heartbeat();
|
|
476
569
|
const deadline = Date.now() + args.timeoutSeconds * 1000;
|
|
477
570
|
for (;;) {
|
|
478
571
|
if (args.ownerPid !== undefined && !processIsAlive(args.ownerPid)) {
|
|
@@ -491,6 +584,10 @@ try {
|
|
|
491
584
|
room_id: hit.room_id,
|
|
492
585
|
room_name: hit.room_name,
|
|
493
586
|
mentions_only: args.mentionsOnly,
|
|
587
|
+
next: `catch_up room ${hit.room_id} until \`remaining\` is 0 or stops ` +
|
|
588
|
+
"falling; then re-run this command before working. The watcher " +
|
|
589
|
+
"checks before sleeping, so unread traffic makes it exit " +
|
|
590
|
+
"immediately.",
|
|
494
591
|
}) + "\n", (error) => (error ? rejectWrite(error) : resolveWrite()));
|
|
495
592
|
});
|
|
496
593
|
exitCode = 0;
|
|
@@ -499,7 +596,11 @@ try {
|
|
|
499
596
|
if (Date.now() >= deadline) {
|
|
500
597
|
if (args.timeoutOk) {
|
|
501
598
|
await new Promise((resolveWrite, rejectWrite) => {
|
|
502
|
-
process.stdout.write(
|
|
599
|
+
process.stdout.write(JSON.stringify({
|
|
600
|
+
has_updates: false,
|
|
601
|
+
timed_out: true,
|
|
602
|
+
next: "re-run this same command immediately to keep watching.",
|
|
603
|
+
}) + "\n", (error) => (error ? rejectWrite(error) : resolveWrite()));
|
|
503
604
|
});
|
|
504
605
|
exitCode = 0;
|
|
505
606
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "multi-agent-collaboration-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "MCP server: a shared chat room where AI agents coordinate via a SQLite-backed message log",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"bin": {
|
|
14
14
|
"multi-agent-collaboration-mcp": "dist/index.js",
|
|
15
15
|
"agent-chat-check": "dist/check.js",
|
|
16
|
-
"agent-chat-poller": "dist/poller.js"
|
|
16
|
+
"agent-chat-poller": "dist/poller.js",
|
|
17
|
+
"agent-chat-web": "web/server.mjs"
|
|
17
18
|
},
|
|
18
19
|
"main": "dist/index.js",
|
|
19
20
|
"files": [
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
const connectionId = randomUUID();
|
|
10
|
+
const probeStartedAt = new Date().toISOString();
|
|
11
|
+
let nickname = null;
|
|
12
|
+
let loginCalls = 0;
|
|
13
|
+
|
|
14
|
+
const SENSITIVE_KEY =
|
|
15
|
+
/authorization|cookie|credential|password|secret|session|token|api[-_]?key/i;
|
|
16
|
+
const MAX_STRING_CHARS = 2_000;
|
|
17
|
+
const MAX_ARRAY_ITEMS = 100;
|
|
18
|
+
const MAX_OBJECT_KEYS = 100;
|
|
19
|
+
const MAX_DEPTH = 6;
|
|
20
|
+
|
|
21
|
+
function fingerprint(value) {
|
|
22
|
+
return createHash("sha256").update(String(value)).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sanitize(value, depth = 0) {
|
|
26
|
+
if (value === null || value === undefined) return value ?? null;
|
|
27
|
+
if (depth >= MAX_DEPTH) return "[depth limit]";
|
|
28
|
+
if (typeof value === "string") {
|
|
29
|
+
return value.length <= MAX_STRING_CHARS
|
|
30
|
+
? value
|
|
31
|
+
: `${value.slice(0, MAX_STRING_CHARS)}...[truncated ${value.length - MAX_STRING_CHARS} chars]`;
|
|
32
|
+
}
|
|
33
|
+
if (
|
|
34
|
+
typeof value === "number" ||
|
|
35
|
+
typeof value === "boolean"
|
|
36
|
+
) {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
if (typeof value === "bigint") return value.toString();
|
|
40
|
+
if (value instanceof URL) {
|
|
41
|
+
const safe = new URL(value);
|
|
42
|
+
if (safe.username) safe.username = "[redacted]";
|
|
43
|
+
if (safe.password) safe.password = "[redacted]";
|
|
44
|
+
for (const key of safe.searchParams.keys()) {
|
|
45
|
+
if (SENSITIVE_KEY.test(key)) safe.searchParams.set(key, "[redacted]");
|
|
46
|
+
}
|
|
47
|
+
return safe.toString();
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
const entries = value
|
|
51
|
+
.slice(0, MAX_ARRAY_ITEMS)
|
|
52
|
+
.map((entry) => sanitize(entry, depth + 1));
|
|
53
|
+
if (value.length > MAX_ARRAY_ITEMS) {
|
|
54
|
+
entries.push(`[truncated ${value.length - MAX_ARRAY_ITEMS} items]`);
|
|
55
|
+
}
|
|
56
|
+
return entries;
|
|
57
|
+
}
|
|
58
|
+
if (typeof value === "object") {
|
|
59
|
+
const entries = Object.entries(value);
|
|
60
|
+
const safe = {};
|
|
61
|
+
for (const [key, entry] of entries.slice(0, MAX_OBJECT_KEYS)) {
|
|
62
|
+
safe[key] = SENSITIVE_KEY.test(key)
|
|
63
|
+
? {
|
|
64
|
+
redacted: true,
|
|
65
|
+
sha256: fingerprint(
|
|
66
|
+
typeof entry === "string" ? entry : JSON.stringify(entry),
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
: sanitize(entry, depth + 1);
|
|
70
|
+
}
|
|
71
|
+
if (entries.length > MAX_OBJECT_KEYS) {
|
|
72
|
+
safe._truncated_keys = entries.length - MAX_OBJECT_KEYS;
|
|
73
|
+
}
|
|
74
|
+
return safe;
|
|
75
|
+
}
|
|
76
|
+
return String(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function safeAuthInfo(authInfo) {
|
|
80
|
+
if (!authInfo) return null;
|
|
81
|
+
return {
|
|
82
|
+
client_id: authInfo.clientId,
|
|
83
|
+
scopes: sanitize(authInfo.scopes),
|
|
84
|
+
expires_at: authInfo.expiresAt ?? null,
|
|
85
|
+
resource: sanitize(authInfo.resource),
|
|
86
|
+
token: {
|
|
87
|
+
redacted: true,
|
|
88
|
+
sha256: fingerprint(authInfo.token),
|
|
89
|
+
},
|
|
90
|
+
extra: sanitize(authInfo.extra),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const server = new McpServer(
|
|
95
|
+
{
|
|
96
|
+
name: "agent-chat-connection-probe",
|
|
97
|
+
version: "0.1.0",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
instructions:
|
|
101
|
+
"Call connection_login. Repeated calls through one MCP connection return " +
|
|
102
|
+
"the same connection_id and nickname. Separately launched MCP processes " +
|
|
103
|
+
"return different values.",
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
server.registerTool(
|
|
108
|
+
"connection_login",
|
|
109
|
+
{
|
|
110
|
+
title: "Inspect MCP connection identity",
|
|
111
|
+
description:
|
|
112
|
+
"Assign and report this MCP server process's connection identity. The " +
|
|
113
|
+
"first call assigns it; later calls reuse it.",
|
|
114
|
+
inputSchema: z.object({}).strict(),
|
|
115
|
+
},
|
|
116
|
+
async (_args, extra) => {
|
|
117
|
+
const assignedNow = nickname === null;
|
|
118
|
+
if (assignedNow) nickname = `connection-${connectionId}`;
|
|
119
|
+
loginCalls += 1;
|
|
120
|
+
return {
|
|
121
|
+
content: [
|
|
122
|
+
{
|
|
123
|
+
type: "text",
|
|
124
|
+
text: JSON.stringify(
|
|
125
|
+
{
|
|
126
|
+
connection_id: connectionId,
|
|
127
|
+
nickname,
|
|
128
|
+
assigned_now: assignedNow,
|
|
129
|
+
login_calls: loginCalls,
|
|
130
|
+
observed_at: new Date().toISOString(),
|
|
131
|
+
process: {
|
|
132
|
+
pid: process.pid,
|
|
133
|
+
parent_pid: process.ppid,
|
|
134
|
+
probe_started_at: probeStartedAt,
|
|
135
|
+
working_directory: process.cwd(),
|
|
136
|
+
executable: process.execPath,
|
|
137
|
+
argv: sanitize(process.argv),
|
|
138
|
+
title: process.title,
|
|
139
|
+
node_version: process.version,
|
|
140
|
+
platform: process.platform,
|
|
141
|
+
architecture: process.arch,
|
|
142
|
+
hostname: hostname(),
|
|
143
|
+
uid: process.getuid?.() ?? null,
|
|
144
|
+
gid: process.getgid?.() ?? null,
|
|
145
|
+
stdin_is_tty: process.stdin.isTTY === true,
|
|
146
|
+
stdout_is_tty: process.stdout.isTTY === true,
|
|
147
|
+
},
|
|
148
|
+
mcp: {
|
|
149
|
+
transport: "stdio",
|
|
150
|
+
transport_session_id:
|
|
151
|
+
extra.sessionId === undefined
|
|
152
|
+
? null
|
|
153
|
+
: {
|
|
154
|
+
redacted: true,
|
|
155
|
+
sha256: fingerprint(extra.sessionId),
|
|
156
|
+
},
|
|
157
|
+
client_reported_info: sanitize(
|
|
158
|
+
server.server.getClientVersion(),
|
|
159
|
+
),
|
|
160
|
+
client_reported_capabilities: sanitize(
|
|
161
|
+
server.server.getClientCapabilities(),
|
|
162
|
+
),
|
|
163
|
+
request: {
|
|
164
|
+
jsonrpc_request_id: extra.requestId,
|
|
165
|
+
task_id: extra.taskId ?? null,
|
|
166
|
+
task_requested_ttl: extra.taskRequestedTtl ?? null,
|
|
167
|
+
metadata: sanitize(extra._meta),
|
|
168
|
+
http:
|
|
169
|
+
extra.requestInfo === undefined
|
|
170
|
+
? null
|
|
171
|
+
: {
|
|
172
|
+
url: sanitize(extra.requestInfo.url),
|
|
173
|
+
headers: sanitize(extra.requestInfo.headers),
|
|
174
|
+
},
|
|
175
|
+
auth: safeAuthInfo(extra.authInfo),
|
|
176
|
+
},
|
|
177
|
+
field_sources: {
|
|
178
|
+
connection_id:
|
|
179
|
+
"Generated by this probe process; not supplied by MCP.",
|
|
180
|
+
client_reported_info:
|
|
181
|
+
"initialize.params.clientInfo supplied by the MCP client.",
|
|
182
|
+
client_reported_capabilities:
|
|
183
|
+
"initialize.params.capabilities supplied by the MCP client.",
|
|
184
|
+
transport_session_id:
|
|
185
|
+
"RequestHandlerExtra.sessionId when the transport supplies one.",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
null,
|
|
190
|
+
2,
|
|
191
|
+
),
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
let shutdownPromise;
|
|
199
|
+
function shutdown(exitCode) {
|
|
200
|
+
if (shutdownPromise) return shutdownPromise;
|
|
201
|
+
process.exitCode = exitCode;
|
|
202
|
+
shutdownPromise = server.close().catch(() => undefined);
|
|
203
|
+
return shutdownPromise;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
process.stdin.once("end", () => void shutdown(0));
|
|
207
|
+
process.once("SIGINT", () => void shutdown(130));
|
|
208
|
+
process.once("SIGTERM", () => void shutdown(143));
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
await server.connect(new StdioServerTransport());
|
|
212
|
+
process.stderr.write(
|
|
213
|
+
`connection probe ready (connection_id: ${connectionId}, pid: ${process.pid})\n`,
|
|
214
|
+
);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
process.stderr.write(
|
|
217
|
+
`connection probe failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
218
|
+
);
|
|
219
|
+
process.exitCode = 1;
|
|
220
|
+
}
|