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/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>] [--epoch <n>]
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 stale binding (the persona was resumed by a newer runtime).
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
- --epoch binds this watcher to one runtime tenure of the persona. Every probe
24
- reads the persona's current epoch; once it moves, this watcher is speaking for
25
- a runtime that no longer holds the persona, so it exits 2 rather than reporting
26
- traffic to a seat nobody is sitting in. Omit it only for a diagnostic watch
27
- that should survive a takeover.
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(`an equivalent watcher is already running (pid ${owner}; lock: ${path})`);
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 requires removal: ${path}`);
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 both --owner-pid and --epoch -- refreshes its
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 that pair (a hand-run diagnostic watch) stays strictly
256
+ // watcher without it (a hand-run diagnostic watch) stays strictly
252
257
  // read-only.
253
- const heartbeatEnabled = args.ownerPid !== undefined && args.epoch !== 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
- // The epoch is PART of the singleton key. Two watchers of the same persona
284
- // and room at different epochs are not duplicates: the older one belongs to a
285
- // runtime that has already been fenced out and will exit on its next probe.
286
- // Keying without it made a freshly-resumed runtime unable to arm a watcher
287
- // until the dying one noticed, leaving the new seat deaf for up to one
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.epoch ?? null,
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 watcher bound to an epoch stops the moment the persona moves on.
308
+ * A terminally retired identity can never watch again.
305
309
  *
306
- * The epoch is carried as returned DATA, never as a WHERE predicate. As a
307
- * predicate it would simply remove the row, and the two probes read a missing
308
- * row very differently: the scoped one reports "the room was deleted or the
309
- * membership disappeared", which is a false and actively misleading
310
- * diagnosis, while the all-rooms one reads it as an ordinary quiet interval
311
- * and never exits at all. Comparing in JS keeps the true reason attached to
312
- * the exit.
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 checkEpoch = (current) => {
315
- if (args.epoch === undefined)
316
- return;
317
- if (current === null) {
318
- argumentError(`persona "${args.agent}" no longer exists; this watcher's binding is dead`);
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
- if (current !== args.epoch) {
321
- argumentError(`stale_binding: persona "${args.agent}" is now at runtime epoch ${current}, ` +
322
- `this watcher was bound at ${args.epoch}. A newer runtime holds the ` +
323
- `persona; regenerate the poller command from that runtime.`);
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
- const membership = database
334
- .prepare("SELECT left_at FROM memberships WHERE room_id = ? AND agent_id = ?")
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, current_epoch and left_at are all COLUMNS of a row this
347
- // query always returns while the room and membership exist, so "no row"
348
- // keeps its single unambiguous meaning.
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
- (SELECT a.runtime_epoch FROM agents a WHERE a.id = @agent)
351
- AS current_epoch,
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
- checkEpoch(row.current_epoch);
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 the epoch does -- as a WHERE
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 stale_binding. Both say do not re-arm
377
- // this command, but one means another runtime holds the persona and the
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
- const present = database
393
- .prepare("SELECT 1 FROM memberships WHERE agent_id = ? AND left_at IS NULL LIMIT 1")
394
- .get(args.agent);
395
- if (!present)
396
- argumentError(`agent "${args.agent}" is not present in any room`);
397
- // ANCHORED on the persona row, not on memberships. The unread test alone
398
- // returns ZERO rows on a quiet interval -- the normal case -- so there is no
399
- // row to hang an epoch column on. Selecting FROM agents makes the result
400
- // exactly one row whenever the persona exists, carrying the epoch every
401
- // time and the hit only when there is one. A vanished persona yields no row,
402
- // which checkEpoch reads as a dead binding rather than as silence.
403
- const statement = database.prepare(`SELECT a.runtime_epoch AS current_epoch,
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
- checkEpoch(row ? row.current_epoch : null);
419
- if (!row || row.room_id === null)
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, epoch-fenced UPDATE. The EXISTS clause is the fence: a watcher
432
- * whose persona has since been resumed elsewhere matches no row, so a stale
433
- * runtime cannot refresh liveness even in the interval before its next probe
434
- * notices and exits. It sets last_seen and nothing else -- left_at is never
435
- * cleared, so this cannot resurrect a seat that left the room. Failure is
436
- * swallowed: a watcher that cannot write must still report traffic.
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.runtime_epoch = @epoch)`)
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('{"has_updates":false,"timed_out":true}\n', (error) => (error ? rejectWrite(error) : resolveWrite()));
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.13.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
+ }