@imenam/mcp-messenger 1.0.30 → 1.0.31

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.
@@ -35,6 +35,30 @@ export interface AgentDirectoryEntry extends Agent {
35
35
  /** Plus récent de la dernière connexion et de la dernière livraison ; null si aucune. */
36
36
  last_activity_at: string | null;
37
37
  }
38
+ /** Une des deux boîtes d'une liaison, et le moment où son agent en a pris connaissance. */
39
+ export interface LinkEnd {
40
+ mailbox: string;
41
+ /** null tant que l'agent de cette boîte n'a pas lu la liaison (`whoami`, `list_links`, `list_agents`). */
42
+ seen_at: string | null;
43
+ }
44
+ /**
45
+ * Liaison posée depuis la console entre deux boîtes : chacune connaît l'autre comme
46
+ * correspondante désignée, sans que les agents aient à échanger d'adresse.
47
+ */
48
+ export interface MailboxLink {
49
+ link_id: string;
50
+ /** Motif transmis aux deux agents. */
51
+ note: string | null;
52
+ created_at: string;
53
+ mailboxes: [LinkEnd, LinkEnd];
54
+ }
55
+ /** Liaison vue depuis l'une de ses deux boîtes : la fiche de la boîte d'en face. */
56
+ export interface LinkedMailbox {
57
+ link_id: string;
58
+ note: string | null;
59
+ created_at: string;
60
+ correspondent: Agent;
61
+ }
38
62
  /** Pièce jointe telle qu'elle voyage avec un message : ses métadonnées, sans son contenu. */
39
63
  export interface AttachmentInfo {
40
64
  attachment_id: string;
@@ -18,28 +18,31 @@ import { TOOLS, callTool } from "./tools.js";
18
18
  * recopier une adresse dans le corps d'un message (le serveur la pose déjà, la copie ment
19
19
  * dès qu'elle vieillit). Les dire ici, c'est les dire avant l'erreur.
20
20
  */
21
- const INSTRUCTIONS = `mcp-messenger gives this session a mailbox and lets it exchange messages with other agent sessions.
22
-
23
- Two words, two different things, used consistently by every tool here:
24
- - a **mailbox** is an address (\`box_…\`). It identifies exactly one session, and it is what you write to.
25
- - an **agent name** is the project a session works for. It is a label, never an address, and every session of that project carries it.
26
-
27
- Who you are, and who the others are — read this before writing to anyone:
28
- - A session is identified by its mailbox address, and by nothing else. \`whoami\` gives you yours.
29
- - The agent name (e.g. "Ai Corrector Dev") comes from the configuration and says which project the agent works on. It is NOT unique: every session started from that configuration carries it.
30
- - \`list_agents\` groups mailboxes under their agent name: one group per project, one line per session. A group holding several lines is several live sessions of that project, each with its own address — pick one of those addresses, never the group name.
31
- - One of those groups may carry the same agent name as yours. Recognising your own agent name there does not mean you are looking at yourself — those are other sessions of the same project, each a different agent with its own mailbox. Compare addresses, never names.
32
- - Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
33
-
34
- Resuming earlier work:
35
- - A resumed session usually keeps the mailbox it had: the address, the threads and the unread messages come back on their own. \`whoami\` says so when it happens.
36
- - When it cannot be decided automatically, \`whoami\` lists the mailboxes left by a stopped session of this project. If you are continuing that work, call \`attach_to_mailbox\` with the right address.
37
- - If a \`box_…\` address appears earlier in your own context, from a previous part of this conversation, that is your mailbox from before: attach to it rather than writing from a fresh one, so your correspondents keep reaching you at the address they know.
38
-
39
- Writing:
40
- - One message goes to exactly one mailbox, and never to your own. To reach several correspondents, send each one its own message.
41
- - Never write a mailbox address in the body of a message. The server attaches the sender and the recipient to every message, and the reader sees them: an address copied by hand adds nothing and misleads as soon as it is stale or mistaken.
42
- - Call \`enable_notifications\` once at the start of the session and arm the watcher it hands you. Until then, no incoming message reaches you.
21
+ const INSTRUCTIONS = `mcp-messenger gives this session a mailbox and lets it exchange messages with other agent sessions.
22
+
23
+ Two words, two different things, used consistently by every tool here:
24
+ - a **mailbox** is an address (\`box_…\`). It identifies exactly one session, and it is what you write to.
25
+ - an **agent name** is the project a session works for. It is a label, never an address, and every session of that project carries it.
26
+
27
+ Who you are, and who the others are — read this before writing to anyone:
28
+ - A session is identified by its mailbox address, and by nothing else. \`whoami\` gives you yours.
29
+ - The agent name (e.g. "Ai Corrector Dev") comes from the configuration and says which project the agent works on. It is NOT unique: every session started from that configuration carries it.
30
+ - \`list_agents\` groups mailboxes under their agent name: one group per project, one line per session. A group holding several lines is several live sessions of that project, each with its own address — pick one of those addresses, never the group name.
31
+ - One of those groups may carry the same agent name as yours. Recognising your own agent name there does not mean you are looking at yourself — those are other sessions of the same project, each a different agent with its own mailbox. Compare addresses, never names.
32
+ - Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
33
+
34
+ Resuming earlier work:
35
+ - A resumed session usually keeps the mailbox it had: the address, the threads and the unread messages come back on their own. \`whoami\` says so when it happens.
36
+ - When it cannot be decided automatically, \`whoami\` lists the mailboxes left by a stopped session of this project. If you are continuing that work, call \`attach_to_mailbox\` with the right address.
37
+ - If a \`box_…\` address appears earlier in your own context, from a previous part of this conversation, that is your mailbox from before: attach to it rather than writing from a fresh one, so your correspondents keep reaching you at the address they know.
38
+
39
+ Linked mailboxes:
40
+ - The user can link this mailbox to others from the web console: those are the correspondents you are meant to work with. \`whoami\` and \`list_links\` list them, and the notification watcher announces each new link as it arrives.
41
+
42
+ Writing:
43
+ - One message goes to exactly one mailbox, and never to your own. To reach several correspondents, send each one its own message.
44
+ - Never write a mailbox address in the body of a message. The server attaches the sender and the recipient to every message, and the reader sees them: an address copied by hand adds nothing and misleads as soon as it is stale or mistaken.
45
+ - Call \`enable_notifications\` once at the start of the session and arm the watcher it hands you. Until then, no incoming message reaches you.
43
46
  - The host stops that watcher after 30 minutes at most. Each time it tells you the watcher expired, re-arm it at once with the same Monitor call; otherwise notifications stop for good.`;
44
47
  /** Serveur MCP prêt à être branché sur n'importe quel transport (stdio en production, mémoire en test). */
45
48
  export function createMcpServer(client) {
@@ -107,6 +107,11 @@ export const TOOLS = [
107
107
  additionalProperties: false,
108
108
  },
109
109
  },
110
+ {
111
+ name: "list_links",
112
+ description: "List the mailboxes the user has linked to this one from the web console, newest first: these are the correspondents you are meant to work with, with the reason the user gave. Write to them with send_message, using the address listed here. Links arrive while you work — the notification watcher announces each new one — and whoami lists them too.",
113
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
114
+ },
110
115
  {
111
116
  name: "list_agents",
112
117
  description: "List the mailboxes known to the server, grouped by agent name — one group per project, one line per session mailbox inside it, most recently active first. An agent name is shared by every session of the same project, so a group with several lines means several live sessions, each with its own mailbox address. Your own mailbox is never listed — you cannot write to yourself. Use it to find the mailbox address you want to write to; pass agent_name to expand a group whose sessions were not all listed.",
@@ -396,6 +401,7 @@ export async function callTool(client, name, rawArgs) {
396
401
  const health = await client.health().catch(() => null);
397
402
  const folders = stats.folders.map((f) => ` ${f.name.padEnd(12)} ${f.total} message(s), ${f.unread} unread`);
398
403
  const self = await client.getAgent(client.agentId).catch(() => null);
404
+ const links = await client.listLinks();
399
405
  const lines = [
400
406
  `Mailbox address: ${client.agentId}`,
401
407
  // Personne n'a besoin qu'on la lui envoie : l'annuaire la donne, et chaque message
@@ -411,6 +417,7 @@ export async function callTool(client, name, rawArgs) {
411
417
  self?.display_name
412
418
  ? `Mailbox name: ${self.display_name}`
413
419
  : "Mailbox name: none — call rename_mailbox so the user can tell this session apart from the others.",
420
+ ...linkLines(links),
414
421
  ];
415
422
  if (client.isAttachedElsewhere) {
416
423
  lines.push(`Attached to another mailbox. This session's own mailbox is ${client.sessionAgentId}.`);
@@ -491,13 +498,27 @@ export async function callTool(client, name, rawArgs) {
491
498
  return `No thread in this mailbox${scope} matching these filters.`;
492
499
  return [`${threads.length} thread(s)${scope}:`, "", ...threads.map(formatThread)].join("\n");
493
500
  }
501
+ case "list_links": {
502
+ const links = await client.listLinks();
503
+ if (links.length === 0) {
504
+ return "No mailbox is linked to this one yet. The user links mailboxes from the web console; the notification watcher announces each new link.";
505
+ }
506
+ return [
507
+ `${links.length} mailbox(es) linked to this one by the user, newest first:`,
508
+ "",
509
+ ...links.map(formatLink),
510
+ "",
511
+ "Write to them with send_message, using the address shown. Do not repeat the address in the message body.",
512
+ ].join("\n");
513
+ }
494
514
  case "list_agents": {
495
515
  const input = schemas.list_agents.parse(args);
496
516
  const agents = await client.listAgents();
497
517
  if (agents.length === 0)
498
518
  return "No other mailbox on this server yet.";
519
+ const linked = new Set((await client.listLinks()).map((link) => link.correspondent.id));
499
520
  const groups = groupByAgentName(agents);
500
- return input.agent_name ? formatAgentGroup(groups, input.agent_name) : formatDirectory(groups);
521
+ return input.agent_name ? formatAgentGroup(groups, input.agent_name, linked) : formatDirectory(groups, linked);
501
522
  }
502
523
  case "send_message": {
503
524
  const input = schemas.send_message.parse(args);
@@ -744,9 +765,9 @@ function groupByAgentName(agents) {
744
765
  return [...groups.values()];
745
766
  }
746
767
  /** `boîte — nom · activité` : une ligne par session, l'adresse en tête car c'est elle qu'on copie. */
747
- function formatMailboxLine(agent) {
768
+ function formatMailboxLine(agent, linked) {
748
769
  const seen = agent.last_activity_at ? `last active ${agent.last_activity_at}` : "never active";
749
- const flags = agent.registered ? "" : " · never connected";
770
+ const flags = `${agent.registered ? "" : " · never connected"}${linked.has(agent.id) ? " · linked to you" : ""}`;
750
771
  return ` • ${agent.id} — ${agent.display_name ?? "no mailbox name"} · ${seen}${flags}`;
751
772
  }
752
773
  /**
@@ -763,7 +784,7 @@ const DIRECTORY_FOOTER = [
763
784
  "is never listed here. Always write to a mailbox address taken from this list, never to a name.",
764
785
  "To see what you have already exchanged with one of them, call list_threads with its address in with_mailbox.",
765
786
  ];
766
- function formatDirectory(groups) {
787
+ function formatDirectory(groups, linked) {
767
788
  const mailboxes = groups.reduce((total, group) => total + group.mailboxes.length, 0);
768
789
  const blocks = groups.map((group) => {
769
790
  const sessions = group.mailboxes.length;
@@ -773,7 +794,7 @@ function formatDirectory(groups) {
773
794
  // Un agent à sessions nombreuses ne doit pas noyer les autres : les plus récentes
774
795
  // suffisent à écrire, `agent_name` donne le reste à qui en a besoin.
775
796
  const shown = sessions > SESSIONS_SHOWN_PER_AGENT ? group.mailboxes.slice(0, 3) : group.mailboxes;
776
- lines.push(...shown.map(formatMailboxLine));
797
+ lines.push(...shown.map((agent) => formatMailboxLine(agent, linked)));
777
798
  if (shown.length < sessions) {
778
799
  lines.push(` … ${sessions - shown.length} more session(s) — list_agents with agent_name: "${group.name}" lists them all.`);
779
800
  }
@@ -789,7 +810,7 @@ function formatDirectory(groups) {
789
810
  ].join("\n");
790
811
  }
791
812
  /** Toutes les sessions d'un agent, décrites une par une : le second niveau de l'annuaire. */
792
- function formatAgentGroup(groups, requested) {
813
+ function formatAgentGroup(groups, requested, linked) {
793
814
  const wanted = requested.trim().toLowerCase();
794
815
  const group = groups.find((g) => g.name.toLowerCase() === wanted);
795
816
  if (!group) {
@@ -802,7 +823,7 @@ function formatAgentGroup(groups, requested) {
802
823
  `• ${agent.id}`,
803
824
  ` Mailbox: ${agent.display_name ?? "not named — this session never said what it works on"}`,
804
825
  agent.description ? ` ${agent.description}` : null,
805
- ` ${seen}${agent.registered ? "" : " · never connected"}`,
826
+ ` ${seen}${agent.registered ? "" : " · never connected"}${linked.has(agent.id) ? " · linked to you" : ""}`,
806
827
  ]
807
828
  .filter(Boolean)
808
829
  .join("\n");
@@ -862,6 +883,23 @@ function resumeLines(client) {
862
883
  }
863
884
  return lines;
864
885
  }
886
+ /** Ce que `whoami` dit des liaisons posées par l'utilisateur sur cette boîte. */
887
+ function linkLines(links) {
888
+ if (links.length === 0) {
889
+ return ["Linked mailboxes: none yet — the user links mailboxes from the web console."];
890
+ }
891
+ return [
892
+ "Linked mailboxes — correspondents the user designated for this session:",
893
+ ...links.map((link) => ` ${formatLink(link)}`),
894
+ " ↳ write to them with send_message; list_links details them.",
895
+ ];
896
+ }
897
+ /** `• adresse — Agent · Boîte · liée le … — motif` */
898
+ function formatLink(link) {
899
+ const names = formatAgentNames(link.correspondent) || "unnamed mailbox";
900
+ const note = link.note ? ` — ${link.note}` : "";
901
+ return `• ${link.correspondent.id} — ${names} · linked ${link.created_at}${note}`;
902
+ }
865
903
  /** `titre (id)`, ou l'id seul tant que le fil n'a pas été nommé. */
866
904
  function formatThreadRef(title, threadId) {
867
905
  return title ? `${title} (${threadId})` : threadId;
@@ -133,6 +133,10 @@ const purgeSchema = z
133
133
  .refine((input) => [input.before, input.created_before, input.empty].filter((value) => value !== undefined).length === 1, {
134
134
  message: "provide exactly one of `before` (inactive since that date), `created_before` (created before that date) or `empty: true`",
135
135
  });
136
+ const linkSchema = z.object({
137
+ mailboxes: z.tuple([agentIdSchema, agentIdSchema]),
138
+ note: z.string().trim().max(500).nullish(),
139
+ });
136
140
  const adminListSchema = z.object({
137
141
  search: z.string().trim().min(1).optional(),
138
142
  agent: z.string().trim().min(1).optional(),
@@ -209,7 +213,7 @@ export function createApp({ store, config }) {
209
213
  app.get("/", (_req, res) => {
210
214
  res.redirect("/ui/");
211
215
  });
212
- // --- Supervision (lecture, sauf les nettoyages de boîtes) ----------------
216
+ // --- Supervision (lecture, liaisons et nettoyages de boîtes) -------------
213
217
  // Montée avant la porte d'entrée : son contrôle est au moins aussi strict (session de
214
218
  // console ou jeton admin), et la session par cookie n'est reconnue nulle part ailleurs.
215
219
  const admin = express.Router();
@@ -281,7 +285,7 @@ export function createApp({ store, config }) {
281
285
  res.json({ mailboxes: store.emptyMailboxes() });
282
286
  });
283
287
  /**
284
- * La seule route de la console qui écrit, et elle détruit.
288
+ * Suppression en lot : elle détruit.
285
289
  *
286
290
  * Deux garde-fous, parce qu'un nettoyage ne se rattrape pas : le critère est redemandé
287
291
  * avec les adresses, et le serveur refuse toute adresse qui n'y répond plus au moment
@@ -331,6 +335,21 @@ export function createApp({ store, config }) {
331
335
  logger.warn(`admin delete: mailbox ${id} deleted (${removed.messages} message(s) erased)`);
332
336
  res.json({ mailbox: id, ...removed });
333
337
  });
338
+ admin.get("/links", (_req, res) => {
339
+ res.json({ links: store.listLinks() });
340
+ });
341
+ admin.post("/links", (req, res) => {
342
+ const input = linkSchema.parse(req.body ?? {});
343
+ const link = store.createLink(input.mailboxes[0], input.mailboxes[1], input.note ?? null);
344
+ logger.info(`admin link ${link.link_id}: ${link.mailboxes[0].mailbox} <-> ${link.mailboxes[1].mailbox}`);
345
+ res.status(201).json({ link });
346
+ });
347
+ admin.delete("/links/:id", (req, res) => {
348
+ const linkId = String(req.params.id);
349
+ store.deleteLink(linkId);
350
+ logger.info(`admin unlink ${linkId}`);
351
+ res.json({ link_id: linkId });
352
+ });
334
353
  app.use("/admin", admin);
335
354
  // Porte d'entrée de tout le reste : aucune requête n'est servie sans jeton valide. Le
336
355
  // contrôle par route (identité de l'agent) vient ensuite.
@@ -388,6 +407,10 @@ export function createApp({ store, config }) {
388
407
  throw new MessengerError(`mailbox not found: ${req.params.id}`, 404);
389
408
  res.json({ agent });
390
409
  });
410
+ // Lire ses liaisons, c'est en prendre connaissance : la console l'affiche comme reçue.
411
+ api.get("/links", (req, res) => {
412
+ res.json({ links: store.linksFor(req.agentId, { markSeen: true }) });
413
+ });
391
414
  api.get("/mailbox", (req, res) => {
392
415
  res.json({ ...store.stats(req.agentId), watcher: watchers.status(req.agentId) });
393
416
  });
@@ -460,7 +483,8 @@ export function createApp({ store, config }) {
460
483
  });
461
484
  // Attente longue : seuls les messages arrivés APRÈS le début de l'attente réveillent
462
485
  // l'appelant ; les non-lus déjà présents sont ignorés (sinon l'outil rendrait la main
463
- // immédiatement tant que la boîte n'est pas vidée).
486
+ // immédiatement tant que la boîte n'est pas vidée). Un veilleur est aussi réveillé par
487
+ // une liaison posée sur sa boîte.
464
488
  api.get("/wait", (req, res) => {
465
489
  const { timeout_seconds, folder, watcher } = waitQuerySchema.parse(req.query);
466
490
  const agentId = req.agentId;
@@ -471,11 +495,15 @@ export function createApp({ store, config }) {
471
495
  // Le relâchement doit couvrir les trois sorties possibles : message, expiration,
472
496
  // client parti. Un compteur qui ne redescend pas ferait croire à un veilleur éternel.
473
497
  const releaseWatcher = watcher ? watchers.begin(agentId) : () => { };
474
- const done = (payload) => {
498
+ const stop = () => {
475
499
  clearTimeout(timer);
476
500
  store.off("message", onMessage);
477
- req.off("close", onClose);
501
+ store.off("link", onLink);
502
+ req.off("close", stop);
478
503
  releaseWatcher();
504
+ };
505
+ const done = (payload) => {
506
+ stop();
479
507
  if (!res.headersSent)
480
508
  res.json(payload);
481
509
  };
@@ -488,14 +516,17 @@ export function createApp({ store, config }) {
488
516
  return;
489
517
  done({ timed_out: false, message: store.getMessage(agentId, event.message_id, false) });
490
518
  };
491
- const onClose = () => {
492
- clearTimeout(timer);
493
- store.off("message", onMessage);
494
- releaseWatcher();
519
+ const onLink = (event) => {
520
+ if (event.agent_id !== agentId)
521
+ return;
522
+ const [link] = store.linksFor(agentId, { linkId: event.link_id });
523
+ done({ timed_out: false, message: null, link });
495
524
  };
496
525
  const timer = setTimeout(() => done({ timed_out: true, message: null }), timeoutSeconds * 1000);
497
526
  store.on("message", onMessage);
498
- req.once("close", onClose);
527
+ if (watcher)
528
+ store.on("link", onLink);
529
+ req.once("close", stop);
499
530
  });
500
531
  app.use("/api", api);
501
532
  // --- Erreurs --------------------------------------------------------------