@imenam/mcp-messenger 1.0.9 → 1.0.11

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.
@@ -55,11 +55,15 @@ export const TOOLS = [
55
55
  },
56
56
  {
57
57
  name: "list_threads",
58
- description: "List the conversations visible from this mailbox, most recently active first, with their name, participants and unread count. This is the readable map of the ongoing work: prefer it over list_messages when you want to know what is being discussed rather than what arrived last. A thread with no name falls back to the subject of its opening message — name it with rename_thread.",
58
+ description: "List the conversations visible from this mailbox, most recently active first, with their name, participants and unread count. This is the readable map of the ongoing work: prefer it over list_messages when you want to know what is being discussed rather than what arrived last. Set with_mailbox to see everything exchanged with one correspondent. A thread with no name falls back to the subject of its opening message — name it with rename_thread.",
59
59
  inputSchema: {
60
60
  type: "object",
61
61
  properties: {
62
62
  unread_only: { type: "boolean", description: "Keep only threads with unread messages." },
63
+ with_mailbox: {
64
+ type: "string",
65
+ description: "A correspondent's mailbox address, as listed by list_agents: keep only the threads shared with that mailbox. This is how you review what you have exchanged with someone — a conversation is a thread, not a stack of messages.",
66
+ },
63
67
  search: { type: "string", description: "Free text matched against thread name, description and subject." },
64
68
  limit: { type: "number", description: "1-100, defaults to 25." },
65
69
  },
@@ -68,16 +72,25 @@ export const TOOLS = [
68
72
  },
69
73
  {
70
74
  name: "list_agents",
71
- description: "List the other agents known to the mailbox server, most recently active first, with their name and description. Your own mailbox is never listed — you cannot write to yourself. Use it to find the address of the agent you want to write to.",
72
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
75
+ 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.",
76
+ inputSchema: {
77
+ type: "object",
78
+ properties: {
79
+ agent_name: {
80
+ type: "string",
81
+ description: "Name of one agent (project) to expand: lists every one of its session mailboxes in full. Omit it to get the whole directory, grouped.",
82
+ },
83
+ },
84
+ additionalProperties: false,
85
+ },
73
86
  },
74
87
  {
75
88
  name: "send_message",
76
- description: "Send a message to one agent, identified by its mailbox address. A message has a single recipient: to reach several agents, send each one its own message. Set reply_to to answer an existing message and keep the thread together. Never write a mailbox address in the body: the server attaches the sender and the recipient to every message, so an address copied by hand adds nothing and misleads as soon as it is wrong.",
89
+ description: "Send a message to one mailbox, identified by its address. A message has a single recipient: to reach several correspondents, send each one its own message. Set reply_to to answer an existing message and keep the thread together. Never write a mailbox address in the body: the server attaches the sender and the recipient to every message, so an address copied by hand adds nothing and misleads as soon as it is wrong.",
77
90
  inputSchema: {
78
91
  type: "object",
79
92
  properties: {
80
- to: { type: "string", description: "Recipient agent id (mailbox address). Exactly one recipient." },
93
+ to: { type: "string", description: "Recipient mailbox address, as listed by list_agents. Exactly one recipient." },
81
94
  subject: { type: "string", description: "Short subject line." },
82
95
  body: {
83
96
  type: "string",
@@ -97,13 +110,13 @@ export const TOOLS = [
97
110
  },
98
111
  {
99
112
  name: "list_messages",
100
- description: "List messages in this agent's mailbox, newest first, with a short preview. Filter by folder, unread state, sender, thread or free text.",
113
+ description: "List messages in this mailbox, newest first, with a short preview. Filter by folder, unread state, sender, thread or free text.",
101
114
  inputSchema: {
102
115
  type: "object",
103
116
  properties: {
104
117
  folder: { type: "string", description: FOLDER_HINT + " Defaults to inbox." },
105
118
  unread_only: { type: "boolean", description: "Keep only unread messages." },
106
- from: { type: "string", description: "Only messages sent by this agent id." },
119
+ from: { type: "string", description: "Only messages sent by this mailbox address." },
107
120
  thread_id: { type: "string", description: "Only messages of this thread." },
108
121
  search: { type: "string", description: "Free text matched against subject and body." },
109
122
  limit: { type: "number", description: "1-100, defaults to 25." },
@@ -127,7 +140,7 @@ export const TOOLS = [
127
140
  },
128
141
  {
129
142
  name: "get_thread",
130
- description: "Read every message of a thread visible to this agent, oldest first — the full conversation.",
143
+ description: "Read every message of a thread visible to this mailbox, oldest first — the full conversation.",
131
144
  inputSchema: {
132
145
  type: "object",
133
146
  properties: { thread_id: { type: "string", description: "Thread id shown on any message." } },
@@ -164,7 +177,7 @@ export const TOOLS = [
164
177
  },
165
178
  {
166
179
  name: "delete_messages",
167
- description: "Send messages to the trash folder, or erase them from this mailbox for good when permanent is true. Other recipients keep their own copy.",
180
+ description: "Send messages to the trash folder, or erase them from this mailbox for good when permanent is true. The other party keeps its own copy in its own mailbox.",
168
181
  inputSchema: {
169
182
  type: "object",
170
183
  properties: {
@@ -276,9 +289,15 @@ const schemas = {
276
289
  }),
277
290
  list_threads: z.object({
278
291
  unread_only: z.boolean().optional(),
292
+ with_mailbox: z.string().trim().min(1).optional(),
293
+ // Ancien nom du même filtre, plus publié mais toujours accepté : une session l'a pu
294
+ // apprendre avant la mise à jour. Sans cette entrée, zod l'écarterait en silence et
295
+ // l'agent recevrait tous ses fils en croyant les avoir filtrés.
296
+ with_agent: z.string().trim().min(1).optional(),
279
297
  search: z.string().trim().min(1).optional(),
280
298
  limit: z.number().int().optional(),
281
299
  }),
300
+ list_agents: z.object({ agent_name: z.string().trim().min(1).optional() }),
282
301
  create_folder: z.object({ name: z.string().min(1) }),
283
302
  delete_folder: z.object({ name: z.string().min(1) }),
284
303
  wait_for_message: z.object({ timeout_seconds: z.number().int().optional(), folder: z.string().optional() }),
@@ -371,39 +390,28 @@ export async function callTool(client, name, rawArgs) {
371
390
  }
372
391
  case "list_threads": {
373
392
  const input = schemas.list_threads.parse(args);
374
- const threads = await client.listThreads(input);
393
+ // Une adresse désigne une boîte : les outils disent `with_mailbox`. Le protocole HTTP
394
+ // et le stockage gardent `with_agent`, du nom de la colonne, comme `thread_title`
395
+ // garde son nom face au `thread_name` des outils.
396
+ const correspondent = input.with_mailbox ?? input.with_agent;
397
+ const threads = await client.listThreads({
398
+ unread_only: input.unread_only,
399
+ search: input.search,
400
+ limit: input.limit,
401
+ with_agent: correspondent,
402
+ });
403
+ const scope = correspondent ? ` with ${correspondent}` : "";
375
404
  if (threads.length === 0)
376
- return "No thread in this mailbox matching these filters.";
377
- return [`${threads.length} thread(s):`, "", ...threads.map(formatThread)].join("\n");
405
+ return `No thread in this mailbox${scope} matching these filters.`;
406
+ return [`${threads.length} thread(s)${scope}:`, "", ...threads.map(formatThread)].join("\n");
378
407
  }
379
408
  case "list_agents": {
409
+ const input = schemas.list_agents.parse(args);
380
410
  const agents = await client.listAgents();
381
411
  if (agents.length === 0)
382
- return "No other agent on this server yet.";
383
- const directory = agents
384
- .map((agent) => {
385
- const flags = agent.registered ? "" : "never connected";
386
- const names = formatAgentNames(agent);
387
- const title = names ? `${agent.id} — ${names}` : agent.id;
388
- // L'annuaire est classé par cette date : l'afficher rend l'ordre lisible.
389
- const seen = agent.last_activity_at ? `last active ${agent.last_activity_at}` : "never active";
390
- return [
391
- `• ${title}${flags ? ` (${flags})` : ""}`,
392
- ` ${agent.agent_description ?? agent.description ?? "no description"}`,
393
- ` ${seen}`,
394
- ].join("\n");
395
- })
396
- .join("\n");
397
- // Un nom d'agent est celui d'un projet, pas d'une session : plusieurs boîtes le
398
- // portent, y compris celle de l'appelant. Sans ce rappel, un agent croise son propre
399
- // nom d'agent dans cette liste et prend la boîte d'une autre session pour la sienne.
400
- return [
401
- directory,
402
- "",
403
- "Addresses identify sessions; agent names do not. Several mailboxes above may carry the same",
404
- "agent name as yours — they are other sessions of the same project, never you: your own mailbox",
405
- "is never listed here. Always write to an address taken from this list, never to a name.",
406
- ].join("\n");
412
+ return "No other mailbox on this server yet.";
413
+ const groups = groupByAgentName(agents);
414
+ return input.agent_name ? formatAgentGroup(groups, input.agent_name) : formatDirectory(groups);
407
415
  }
408
416
  case "send_message": {
409
417
  const input = schemas.send_message.parse(args);
@@ -555,6 +563,111 @@ async function renameMailbox(client, name, description) {
555
563
  function formatAgentNames(agent) {
556
564
  return [agent.agent_name, agent.display_name].filter(Boolean).join(" · ");
557
565
  }
566
+ /** Boîtes détaillées sous un nom d'agent avant de renvoyer le reste à `agent_name`. */
567
+ const SESSIONS_SHOWN_PER_AGENT = 4;
568
+ const NO_AGENT_NAME = "unnamed agent";
569
+ /**
570
+ * Deux boîtes sans nom d'agent ne sont pas deux sessions du même projet : rien ne le dit.
571
+ * Chacune reste donc son propre groupe, là où un nom partagé les rassemble.
572
+ */
573
+ function groupByAgentName(agents) {
574
+ const groups = new Map();
575
+ for (const agent of agents) {
576
+ const name = agent.agent_name?.trim() ?? "";
577
+ const key = name ? name.toLowerCase() : `\u0000${agent.id}`;
578
+ const group = groups.get(key);
579
+ if (group) {
580
+ group.mailboxes.push(agent);
581
+ group.description ??= agent.agent_description;
582
+ }
583
+ else {
584
+ groups.set(key, {
585
+ name: name || NO_AGENT_NAME,
586
+ key,
587
+ description: agent.agent_description,
588
+ mailboxes: [agent],
589
+ });
590
+ }
591
+ }
592
+ // L'annuaire arrive classé par activité décroissante : l'ordre d'insertion place en tête
593
+ // l'agent dont une session a bougé le plus récemment, et ses boîtes dans le même ordre.
594
+ return [...groups.values()];
595
+ }
596
+ /** `boîte — nom · activité` : une ligne par session, l'adresse en tête car c'est elle qu'on copie. */
597
+ function formatMailboxLine(agent) {
598
+ const seen = agent.last_activity_at ? `last active ${agent.last_activity_at}` : "never active";
599
+ const flags = agent.registered ? "" : " · never connected";
600
+ return ` • ${agent.id} — ${agent.display_name ?? "no mailbox name"} · ${seen}${flags}`;
601
+ }
602
+ /**
603
+ * Rappel joint à toute réponse de l'annuaire.
604
+ *
605
+ * Un nom d'agent est celui d'un projet, pas d'une session : plusieurs boîtes le portent, y
606
+ * compris celle de l'appelant. Sans ce rappel, un agent croise son propre nom d'agent dans
607
+ * cette liste et prend la boîte d'une autre session pour la sienne. Le groupement rend le
608
+ * nom encore plus visible que l'adresse : la mise en garde compte d'autant plus.
609
+ */
610
+ const DIRECTORY_FOOTER = [
611
+ "Addresses identify sessions; agent names do not. Several mailboxes above may carry the same",
612
+ "agent name as yours — they are other sessions of the same project, never you: your own mailbox",
613
+ "is never listed here. Always write to a mailbox address taken from this list, never to a name.",
614
+ "To see what you have already exchanged with one of them, call list_threads with its address in with_mailbox.",
615
+ ];
616
+ function formatDirectory(groups) {
617
+ const mailboxes = groups.reduce((total, group) => total + group.mailboxes.length, 0);
618
+ const blocks = groups.map((group) => {
619
+ const sessions = group.mailboxes.length;
620
+ const lines = [`${group.name} — ${sessions} session(s)`];
621
+ if (group.description)
622
+ lines.push(` ${group.description}`);
623
+ // Un agent à sessions nombreuses ne doit pas noyer les autres : les plus récentes
624
+ // suffisent à écrire, `agent_name` donne le reste à qui en a besoin.
625
+ const shown = sessions > SESSIONS_SHOWN_PER_AGENT ? group.mailboxes.slice(0, 3) : group.mailboxes;
626
+ lines.push(...shown.map(formatMailboxLine));
627
+ if (shown.length < sessions) {
628
+ lines.push(` … ${sessions - shown.length} more session(s) — list_agents with agent_name: "${group.name}" lists them all.`);
629
+ }
630
+ return lines.join("\n");
631
+ });
632
+ // Un agent par bloc, séparés par une ligne vide : le groupe est l'unité de lecture.
633
+ return [
634
+ `${groups.length} agent(s), ${mailboxes} mailbox(es):`,
635
+ "",
636
+ blocks.join("\n\n"),
637
+ "",
638
+ ...DIRECTORY_FOOTER,
639
+ ].join("\n");
640
+ }
641
+ /** Toutes les sessions d'un agent, décrites une par une : le second niveau de l'annuaire. */
642
+ function formatAgentGroup(groups, requested) {
643
+ const wanted = requested.trim().toLowerCase();
644
+ const group = groups.find((g) => g.name.toLowerCase() === wanted);
645
+ if (!group) {
646
+ const known = groups.map((g) => g.name).join(", ");
647
+ throw new Error(`No agent named "${requested}" on this server. Known agent names: ${known}`);
648
+ }
649
+ const blocks = group.mailboxes.map((agent) => {
650
+ const seen = agent.last_activity_at ? `last active ${agent.last_activity_at}` : "never active";
651
+ return [
652
+ `• ${agent.id}`,
653
+ ` Mailbox: ${agent.display_name ?? "not named — this session never said what it works on"}`,
654
+ agent.description ? ` ${agent.description}` : null,
655
+ ` ${seen}${agent.registered ? "" : " · never connected"}`,
656
+ ]
657
+ .filter(Boolean)
658
+ .join("\n");
659
+ });
660
+ return [
661
+ `${group.name} — ${group.mailboxes.length} session(s), most recently active first:`,
662
+ group.description ?? null,
663
+ "",
664
+ ...blocks,
665
+ "",
666
+ ...DIRECTORY_FOOTER,
667
+ ]
668
+ .filter((line) => line !== null)
669
+ .join("\n");
670
+ }
558
671
  async function renameThread(client, threadId, name, description) {
559
672
  const thread = await client.setThreadLabel(threadId, { title: name, description });
560
673
  return [
@@ -45,6 +45,7 @@ const threadLabelSchema = z
45
45
  });
46
46
  const listThreadsQuerySchema = z.object({
47
47
  unread_only: z.coerce.boolean().optional(),
48
+ with_agent: z.string().trim().min(1).optional(),
48
49
  search: z.string().trim().min(1).optional(),
49
50
  limit: z.coerce.number().int().min(1).max(100).optional(),
50
51
  offset: z.coerce.number().int().min(0).optional(),
@@ -78,6 +79,14 @@ const waitQuerySchema = z.object({
78
79
  watcher: z.enum(["true", "1"]).optional(),
79
80
  });
80
81
  const loginSchema = z.object({ token: z.string().min(1).max(1024) });
82
+ const adminThreadsSchema = z.object({
83
+ search: z.string().trim().min(1).optional(),
84
+ agent: z.string().trim().min(1).optional(),
85
+ unread_only: z.enum(["true", "1"]).optional(),
86
+ priority: z.enum(PRIORITIES).optional(),
87
+ limit: z.coerce.number().int().min(1).max(100).optional(),
88
+ offset: z.coerce.number().int().min(0).optional(),
89
+ });
81
90
  const adminListSchema = z.object({
82
91
  search: z.string().trim().min(1).optional(),
83
92
  agent: z.string().trim().min(1).optional(),
@@ -161,12 +170,26 @@ export function createApp({ store, config }) {
161
170
  admin.get("/messages/:id", (req, res) => {
162
171
  res.json(store.getMessageAdmin(String(req.params.id)));
163
172
  });
173
+ // Déclarée avant `/threads/:id` : la console lit d'abord les fils, le détail ensuite.
174
+ admin.get("/threads", (req, res) => {
175
+ const query = adminThreadsSchema.parse(req.query);
176
+ res.json({
177
+ threads: store.listAllThreadsAdmin({
178
+ search: query.search,
179
+ agent: query.agent,
180
+ unread_only: query.unread_only !== undefined,
181
+ priority: query.priority,
182
+ limit: query.limit,
183
+ offset: query.offset,
184
+ }),
185
+ });
186
+ });
164
187
  admin.get("/threads/:id", (req, res) => {
165
188
  const threadId = String(req.params.id);
166
- const messages = store.listAllMessages({ thread_id: threadId, limit: 100 });
189
+ const messages = store.listThreadAdmin(threadId);
167
190
  if (messages.length === 0)
168
191
  throw new MessengerError(`thread not found: ${threadId}`, 404);
169
- res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages: messages.slice().reverse() });
192
+ res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages });
170
193
  });
171
194
  app.use("/admin", admin);
172
195
  // Porte d'entrée de tout le reste : aucune requête n'est servie sans jeton valide. Le