@imenam/mcp-messenger 1.0.27 → 1.0.29

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 CHANGED
@@ -37,16 +37,20 @@ identifiée par une adresse opaque du type `box_9f2c4a01b3d7e5f8` — voir
37
37
  ### 1. Lancer la boîte aux lettres
38
38
 
39
39
  ```bash
40
- # Un jeton, à conserver : il sera demandé au serveur, aux agents et à la console.
41
- npx @imenam/mcp-messenger --generate-token
40
+ # Deux jetons distincts : celui des agents, et celui de la console.
41
+ npx @imenam/mcp-messenger --generate-token # → jeton client
42
+ npx @imenam/mcp-messenger --generate-token # → jeton console
42
43
 
43
- MESSENGER_TOKEN=<le-jeton> npx @imenam/mcp-messenger --server
44
+ MESSENGER_TOKEN=<jeton-client> MESSENGER_ADMIN_TOKEN=<jeton-console> \
45
+ npx @imenam/mcp-messenger --server
44
46
  # → listening on http://127.0.0.1:3600 (loopback only)
45
47
  # → auth: shared token — required on every request
48
+ # → WARN shared token: no isolation between mailboxes — any holder can act as any address
49
+ # → WARN set MESSENGER_AGENT_TOKENS (one token per agent) to compartmentalize
46
50
  # → web console: http://127.0.0.1:3600/ui/ (asks for MESSENGER_ADMIN_TOKEN once)
47
51
  ```
48
52
 
49
- Deux invariants, non négociables et vérifiés par les tests :
53
+ Trois invariants, non négociables et vérifiés par les tests :
50
54
 
51
55
  1. **Le serveur écoute toujours sur `127.0.0.1`.** `MESSENGER_HOST` n'est pas un réglage :
52
56
  toute autre valeur fait échouer le démarrage. Pour joindre la boîte depuis une autre
@@ -55,6 +59,20 @@ Deux invariants, non négociables et vérifiés par les tests :
55
59
  et données de la console (`/admin`) comprises. Seule la page de la console, qui ne
56
60
  contient aucun secret, est servie librement. Sans `MESSENGER_TOKEN` ni
57
61
  `MESSENGER_AGENT_TOKENS`, le serveur refuse de démarrer.
62
+ 3. **Le jeton de la console n'est jamais celui des agents.** `MESSENGER_ADMIN_TOKEN` est
63
+ exigé dans tous les cas, et le démarrage échoue s'il vaut `MESSENGER_TOKEN`. La console
64
+ voit toutes les boîtes de tous les projets et sait les supprimer, alors que le jeton
65
+ client est recopié dans le `.mcp.json` de chaque projet : les confondre donnerait ce
66
+ pouvoir à tout porteur de ce fichier.
67
+
68
+ > **Le mode jeton partagé ne cloisonne pas les boîtes.** `MESSENGER_TOKEN` atteste
69
+ > l'appartenance à la flotte, pas une adresse : l'en-tête `X-Agent-Id` qui accompagne chaque
70
+ > appel n'est alors adossé à rien, et tout porteur du jeton peut lire, envoyer et supprimer
71
+ > au nom de n'importe quelle boîte. C'est un mode « cercle de confiance », adapté aux
72
+ > sessions d'une même personne sur une même machine — le serveur le rappelle au démarrage.
73
+ > Dès que les agents ne se font pas mutuellement confiance, passer à
74
+ > [`MESSENGER_AGENT_TOKENS`](#serveur), qui lie chaque adresse à son propre
75
+ > jeton et rejette toute autre combinaison.
58
76
 
59
77
  ### 2. Déclarer le MCP côté agent
60
78
 
@@ -118,11 +136,10 @@ enable_notifications() → retourne immédiatement, ne bloque rien
118
136
  Last step — call the Monitor tool now, with exactly these arguments:
119
137
  command: npx -y @imenam/mcp-messenger --watch box_9f2c4a01b3d7e5f8
120
138
  description: Incoming messenger messages
121
- persistent: true
139
+ timeout_ms: 1800000
122
140
  ```
123
141
 
124
- L'agent recopie ces trois arguments dans l'outil `Monitor` de son hôte, et c'est fini pour la
125
- session. Le veilleur tourne en fond, tient une attente longue sur `/api/wait`, et écrit **une
142
+ L'agent recopie ces trois arguments dans l'outil `Monitor` de son hôte. Le veilleur tourne en fond, tient une attente longue sur `/api/wait`, et écrit **une
126
143
  ligne par message** sur sa sortie standard :
127
144
 
128
145
  ```
@@ -131,6 +148,13 @@ ligne par message** sur sa sortie standard :
131
148
 
132
149
  Chaque ligne réveille l'agent, qui appelle `read_message` s'il décide de traiter le message.
133
150
 
151
+ **Réarmer à l'expiration.** Claude Code arrête tout `Monitor` au bout de 30 minutes au plus,
152
+ quelle que soit la durée demandée, et prévient l'agent. Ce minuteur appartient à l'hôte : le
153
+ veilleur ne peut ni le prolonger ni le remettre à zéro. L'agent relance donc le même appel
154
+ `Monitor` à chaque expiration, sans repasser par `enable_notifications` — la réponse de cet
155
+ outil, les instructions du serveur et le bloc `CLAUDE.md` le lui disent. Le délai de grâce
156
+ décrit plus bas couvre l'intervalle : `whoami` reste à « armed » pendant le réarmement.
157
+
134
158
  **Pourquoi deux appels et pas un.** Armer un réveil, c'est ouvrir un canal vers la boucle de
135
159
  la session hôte. Un serveur MCP ne peut pas le faire seul : `Monitor` est un outil de l'hôte,
136
160
  seul le modèle peut l'appeler. `enable_notifications` fait donc tout le reste — il connaît
@@ -474,7 +498,7 @@ navigateur de l'administrateur. Les routes `/admin` acceptent toujours le jeton
474
498
  quart d'heure et par IP.
475
499
 
476
500
  La console n'envoie ni ne modifie aucun message. Les seules écritures qu'elle commande
477
- sont des suppressions, derrière l'icône de corbeille de la barre du haut. **Deux
501
+ sont des suppressions, derrière l'icône de corbeille de la barre du haut. **Trois
478
502
  nettoyages y cohabitent, avec chacun son bouton, parce qu'ils ne visent pas la même
479
503
  chose :**
480
504
 
@@ -485,12 +509,16 @@ chose :**
485
509
  boîte à regrouper ;
486
510
  - **les boîtes inactives depuis une date** : elles ont servi, mais plus rien ne les a
487
511
  touchées depuis. **Une boîte créée après cette date est épargnée, même vide** — une
488
- session qui vient de démarrer et n'a pas encore parlé n'est pas une boîte à l'abandon.
512
+ session qui vient de démarrer et n'a pas encore parlé n'est pas une boîte à l'abandon ;
513
+ - **les boîtes créées avant une date** : le critère est l'âge de la boîte, et lui seul.
514
+ **Une boîte ouverte avant la coupure en fait partie même si elle a parlé ce matin** —
515
+ c'est l'exact inverse du nettoyage précédent, et c'est le geste à passer pour remettre
516
+ un serveur à une date donnée plutôt que pour y faire le tri.
489
517
 
490
518
  S'y ajoute la suppression d'**une boîte en particulier** : chaque ligne de la colonne de
491
519
  gauche porte une corbeille, révélée au survol ou au clavier. Elle n'efface rien d'elle-même,
492
520
  elle ouvre la même fenêtre sur cette seule boîte, avec ce qu'elle contient sous les yeux.
493
- C'est la seule des trois qui ne demande aucun critère : l'adresse vient d'être montrée du
521
+ C'est la seule des quatre qui ne demande aucun critère : l'adresse vient d'être montrée du
494
522
  doigt, il n'y a rien à revérifier.
495
523
 
496
524
  Toutes se font en deux temps, parce qu'elles ne se rattrapent pas :
@@ -526,7 +554,8 @@ Toutes les variables sont documentées dans [`.env.example`](.env.example). L'es
526
554
  | `MESSENGER_DB_PATH` | `./data/messenger.db` | Fichier SQLite |
527
555
  | `MESSENGER_TOKEN` | — | **Obligatoire** (sauf si `MESSENGER_AGENT_TOKENS`) : jeton exigé sur toute requête. `--generate-token` en produit un |
528
556
  | `MESSENGER_AGENT_TOKENS` | — | Un jeton par agent : `planner:tok-a,coder:tok-b` |
529
- | `MESSENGER_ADMIN_TOKEN` | `MESSENGER_TOKEN` | Jeton de la console ; obligatoire si seul `MESSENGER_AGENT_TOKENS` est défini |
557
+ | `MESSENGER_ADMIN_TOKEN` | — | **Obligatoire**, et toujours différent de `MESSENGER_TOKEN` : jeton de la console, qui voit et supprime toutes les boîtes |
558
+ | `MESSENGER_TRUST_PROXY` | `1` | Proxys de confiance devant le serveur (nombre de sauts, `loopback`, ou `false`). Sans lui, toutes les requêtes venues du proxy se ressemblent : le quota devient un compteur unique et le cookie perd `Secure` |
530
559
  | `MESSENGER_RATE_LIMIT_MAX` | `600` | Requêtes par IP et par fenêtre |
531
560
  | `MESSENGER_RATE_LIMIT_WINDOW_MINUTES` | `15` | Durée de la fenêtre |
532
561
  | `MESSENGER_TRASH_RETENTION_DAYS` | `30` | Purge automatique de la corbeille (`0` = jamais) |
@@ -577,8 +606,9 @@ la console (cookie), qui elle n'ouvre rien d'autre.
577
606
  | `GET` | `/admin/threads` | Fils du serveur entier (`agent` — une ou plusieurs adresses séparées par des virgules —, `search`, `unread_only`, `priority`, `limit`, `offset`) |
578
607
  | `GET` | `/admin/threads/:id` | Fil complet, corps inclus |
579
608
  | `GET` | `/admin/mailboxes/stale` | Boîtes sans activité depuis `before`, avec ce que chacune contient |
609
+ | `GET` | `/admin/mailboxes/created-before` | Boîtes ouvertes avant `created_before`, quelle que soit leur activité |
580
610
  | `GET` | `/admin/mailboxes/empty` | Boîtes par lesquelles rien n'est jamais passé, sans critère de date |
581
- | `POST` | `/admin/mailboxes/purge` | Supprime les boîtes nommées (`mailboxes`, plus `before` **ou** `empty: true`) — refusé si l'une d'elles ne répond plus au critère |
611
+ | `POST` | `/admin/mailboxes/purge` | Supprime les boîtes nommées (`mailboxes`, plus exactement un critère : `before`, `created_before` **ou** `empty: true`) — refusé si l'une d'elles ne répond plus au critère |
582
612
  | `DELETE` | `/admin/mailboxes/:id` | Supprime une boîte désignée, quel que soit son état — 404 si l'adresse est inconnue |
583
613
  | `POST` | `/ui/login` · `/ui/logout` | Session de la console (`{ "token": … }` → cookie `HttpOnly`) |
584
614
 
@@ -606,7 +636,13 @@ la console (cookie), qui elle n'ouvre rien d'autre.
606
636
  suppriment des boîtes : réservées au jeton d'administration, confirmées deux fois, et
607
637
  revérifiées par le serveur quand elles portent sur un lot.
608
638
  - Derrière un reverse proxy TLS, exposer le serveur en `https://` et donner cette URL aux
609
- agents via `MESSENGER_SERVER_URL`.
639
+ agents via `MESSENGER_SERVER_URL`. Renseigner `MESSENGER_TRUST_PROXY` (défaut `1`) : c'est
640
+ lui qui rend au serveur l'adresse réelle de l'appelant, donc un quota par client plutôt
641
+ qu'un compteur unique, et le `Secure` du cookie de console.
642
+ - **Le jeton posé dans le bloc `env` d'un `.mcp.json` survit à ce fichier.** L'hôte enregistre
643
+ la configuration du serveur MCP dans ses transcriptions de session — pour Claude Code,
644
+ `~/.claude/projects/*.jsonl`, en clair. Le retirer du `.mcp.json` ne l'en efface pas :
645
+ après une fuite, il faut faire tourner le jeton, pas seulement corriger le fichier.
610
646
 
611
647
  ---
612
648
 
@@ -645,8 +681,9 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
645
681
 
646
682
  ### Tests
647
683
 
648
- 287 tests couvrant : les suppressions de boîtes commandées depuis la console — une boîte
649
- désignée à la main, les vides, celles sans activité depuis une date — avec leur aperçu,
684
+ 309 tests couvrant : les suppressions de boîtes commandées depuis la console — une boîte
685
+ désignée à la main, les vides, celles sans activité depuis une date, celles créées avant
686
+ une date — avec leur aperçu,
650
687
  leur double confirmation et le refus nominatif d'une boîte qui ne répond plus au critère, la reprise de la boîte d'une session interrompue (clé de session de
651
688
  l'hôte, boîte seulement proposée à défaut, boîte d'une session vivante jamais reprise), le rendu markdown des messages et l'aplatissement des aperçus (dont
652
689
  l'innocuité du rendu : ni HTML interprété, ni lien vers un autre schéma qu'`http`, `https`
@@ -56,6 +56,10 @@ Au démarrage de la session, avant toute autre chose : appeler \`enable_notifica
56
56
  puis armer l'outil \`Monitor\` avec la commande exacte qu'il retourne. Sans cela, aucun
57
57
  message ne parviendra à l'agent — il ne l'apprendra qu'en appelant \`list_messages\`.
58
58
 
59
+ L'hôte arrête le veilleur au bout de 30 minutes au plus. À chaque expiration signalée,
60
+ le réarmer aussitôt avec le même appel \`Monitor\`, sans repasser par
61
+ \`enable_notifications\`.
62
+
59
63
  Une fois le veilleur armé, ne rien attendre : les messages entrants interrompent l'agent
60
64
  d'eux-mêmes, y compris à l'arrêt. \`wait_for_message\` ne sert plus qu'à bloquer
61
65
  volontairement sur une réponse précise.
@@ -138,9 +142,15 @@ export function runSetupMcp({ cwd, force }) {
138
142
  console.log(` ${name.padEnd(28)} ${description}`);
139
143
  }
140
144
  console.log("");
141
- console.log("Le serveur boîte aux lettres doit tourner à part, avec le même jeton :");
142
- console.log(` MESSENGER_TOKEN=<votre-jeton> npx -y ${PACKAGE_NAME} --server`);
143
- console.log(` console web : ${DEFAULT_SERVER_URL}/ui/ (le jeton y est demandé une fois)`);
145
+ console.log("Le serveur boîte aux lettres doit tourner à part, avec le même jeton client");
146
+ console.log("et un second jeton, réservé à la console — les deux doivent différer :");
147
+ console.log(` npx -y ${PACKAGE_NAME} --generate-token (une fois par jeton)`);
148
+ console.log(` MESSENGER_TOKEN=<jeton-client> MESSENGER_ADMIN_TOKEN=<jeton-console> \\`);
149
+ console.log(` npx -y ${PACKAGE_NAME} --server`);
150
+ console.log(` console web : ${DEFAULT_SERVER_URL}/ui/ (le jeton console y est demandé une fois)`);
151
+ console.log("");
152
+ console.log("Le jeton client va dans le .mcp.json ci-dessus ; le jeton console n'y va jamais.");
153
+ console.log("Il ouvre toutes les boîtes de tous les projets et permet de les supprimer.");
144
154
  console.log("");
145
155
  console.log("Chaque session d'agent reçoit sa propre boîte : demandez son adresse à l'agent");
146
156
  console.log("(outil whoami) pour la transmettre à un autre agent, ou pour la reprendre plus tard.");
@@ -12,8 +12,17 @@ export interface ServerConfig {
12
12
  token: string | null;
13
13
  /** Jeton dédié par agent : `agent_id -> token`. Prioritaire sur `token`. */
14
14
  agentTokens: Map<string, string>;
15
- /** Jeton de l'interface de supervision. À défaut, `token` fait office. Jamais nul. */
15
+ /** Jeton de l'interface de supervision. Toujours distinct de `token`, et exigé. */
16
16
  adminToken: string;
17
+ /**
18
+ * Valeur passée à `trust proxy` d'Express. Le serveur n'écoute qu'en loopback et se
19
+ * déploie donc derrière un reverse proxy : sans ce réglage, `req.ip` vaut `127.0.0.1`
20
+ * pour tout le monde — le quota devient un compteur unique partagé par tous les clients,
21
+ * et `req.secure` reste faux même en HTTPS.
22
+ *
23
+ * `1` par défaut : un seul proxy devant, le cas documenté. `0` désactive.
24
+ */
25
+ trustProxy: number | string | boolean;
17
26
  rateLimit: {
18
27
  max: number;
19
28
  windowMs: number;
@@ -53,9 +53,20 @@ export function loadServerConfig(env = process.env) {
53
53
  if (!token && agentTokens.size === 0) {
54
54
  throw new ConfigError("a token is required: set MESSENGER_TOKEN (shared) or MESSENGER_AGENT_TOKENS (one token per agent).");
55
55
  }
56
- const adminToken = nonEmpty(env.MESSENGER_ADMIN_TOKEN) ?? token;
56
+ // Jamais de repli sur `token` : la console d'administration voit toutes les boîtes de
57
+ // tous les projets et sait les supprimer, alors que le jeton partagé est distribué à
58
+ // chaque client MCP. Les faire coïncider donnerait ce pouvoir à tout porteur du jeton —
59
+ // en silence, et précisément dans la configuration la plus courante.
60
+ const adminToken = nonEmpty(env.MESSENGER_ADMIN_TOKEN);
57
61
  if (!adminToken) {
58
- throw new ConfigError("MESSENGER_ADMIN_TOKEN is required when only MESSENGER_AGENT_TOKENS is set: the web console needs its own token.");
62
+ throw new ConfigError("MESSENGER_ADMIN_TOKEN is required: the web console sees every mailbox and can delete them, " +
63
+ "so it never shares the token handed to MCP clients. " +
64
+ "Generate one with `npx @imenam/mcp-messenger --generate-token`.");
65
+ }
66
+ // Le poser à la même valeur à la main rouvrirait exactement la porte que l'on ferme.
67
+ if (token && adminToken === token) {
68
+ throw new ConfigError("MESSENGER_ADMIN_TOKEN must differ from MESSENGER_TOKEN: the shared token is distributed to " +
69
+ "every MCP client, and the admin token opens the whole console.");
59
70
  }
60
71
  return {
61
72
  port,
@@ -64,6 +75,7 @@ export function loadServerConfig(env = process.env) {
64
75
  token,
65
76
  agentTokens,
66
77
  adminToken,
78
+ trustProxy: parseTrustProxy(env.MESSENGER_TRUST_PROXY),
67
79
  rateLimit: {
68
80
  max: parseIntEnv(env.MESSENGER_RATE_LIMIT_MAX, 600, "MESSENGER_RATE_LIMIT_MAX"),
69
81
  windowMs: parseIntEnv(env.MESSENGER_RATE_LIMIT_WINDOW_MINUTES, 15, "MESSENGER_RATE_LIMIT_WINDOW_MINUTES") * 60_000,
@@ -121,6 +133,25 @@ function parseAgentTokens(raw) {
121
133
  }
122
134
  return map;
123
135
  }
136
+ /**
137
+ * Nombre de proxys de confiance devant le serveur, ou une expression qu'Express comprend
138
+ * (`loopback`, une liste d'adresses...). Défaut `1` : le déploiement documenté met un
139
+ * reverse proxy devant, et c'est lui qui pose `X-Forwarded-For` / `X-Forwarded-Proto`.
140
+ */
141
+ function parseTrustProxy(raw) {
142
+ const value = nonEmpty(raw);
143
+ if (value === null)
144
+ return 1;
145
+ if (value === "false")
146
+ return false;
147
+ if (value === "true")
148
+ return true;
149
+ // Un nombre est un nombre de sauts : Express ne retient alors que l'adresse posée par le
150
+ // proxy le plus proche, celle qu'un client ne peut pas forger.
151
+ if (/^\d+$/.test(value))
152
+ return Number(value);
153
+ return value;
154
+ }
124
155
  function nonEmpty(value) {
125
156
  const trimmed = value?.trim();
126
157
  return trimmed ? trimmed : null;
@@ -98,6 +98,17 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
98
98
  staleMailboxes(before: string): (AgentDirectoryEntry & {
99
99
  messages: number;
100
100
  })[];
101
+ /**
102
+ * Boîtes ouvertes avant `before`, quoi qu'elles aient fait depuis.
103
+ *
104
+ * L'âge se lit ici sur la seule date de création : une boîte née avant la coupure en
105
+ * fait partie même si elle a parlé ce matin. C'est l'inverse de `staleMailboxes`, qui
106
+ * épargne tout ce qui bouge encore — les deux critères se ressemblent, et c'est la
107
+ * raison pour laquelle ils sont deux.
108
+ */
109
+ mailboxesCreatedBefore(before: string): (AgentDirectoryEntry & {
110
+ messages: number;
111
+ })[];
101
112
  /**
102
113
  * Boîtes par lesquelles rien n'est jamais passé, ni envoi ni réception.
103
114
  *
@@ -603,6 +603,28 @@ export class MessageStore extends EventEmitter {
603
603
  last_activity_at: row.last_activity_at || null,
604
604
  }));
605
605
  }
606
+ /**
607
+ * Boîtes ouvertes avant `before`, quoi qu'elles aient fait depuis.
608
+ *
609
+ * L'âge se lit ici sur la seule date de création : une boîte née avant la coupure en
610
+ * fait partie même si elle a parlé ce matin. C'est l'inverse de `staleMailboxes`, qui
611
+ * épargne tout ce qui bouge encore — les deux critères se ressemblent, et c'est la
612
+ * raison pour laquelle ils sont deux.
613
+ */
614
+ mailboxesCreatedBefore(before) {
615
+ const rows = this.db
616
+ .prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at,
617
+ (SELECT COUNT(*) FROM deliveries d WHERE d.agent_id = a.id) AS messages
618
+ FROM agents a
619
+ WHERE a.created_at < ?
620
+ ORDER BY a.created_at ASC`)
621
+ .all(before);
622
+ return rows.map((row) => ({
623
+ ...row,
624
+ registered: Boolean(row.registered),
625
+ last_activity_at: row.last_activity_at || null,
626
+ }));
627
+ }
606
628
  /**
607
629
  * Boîtes par lesquelles rien n'est jamais passé, ni envoi ni réception.
608
630
  *
@@ -39,7 +39,8 @@ Resuming earlier work:
39
39
  Writing:
40
40
  - One message goes to exactly one mailbox, and never to your own. To reach several correspondents, send each one its own message.
41
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.`;
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.
43
+ - 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.`;
43
44
  /** Serveur MCP prêt à être branché sur n'importe quel transport (stdio en production, mémoire en test). */
44
45
  export function createMcpServer(client) {
45
46
  const server = new Server({ name: "mcp-messenger", version: APP_VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
@@ -5,6 +5,8 @@ import { rebind } from "./mailbox-registry.js";
5
5
  import { forgetCandidate, getResumeState } from "./resume-state.js";
6
6
  /** Nom publié : l'agent recopie la commande, il n'a pas à connaître de chemin local. */
7
7
  const PACKAGE_NAME = "@imenam/mcp-messenger";
8
+ /** Durée maximale d'un `Monitor` côté hôte : au-delà, l'hôte la ramène à cette valeur. */
9
+ const MONITOR_TIMEOUT_MS = 1_800_000;
8
10
  const FOLDER_HINT = `Folder name. System folders: ${SYSTEM_FOLDERS.join(", ")}. Custom folders come from list_folders.`;
9
11
  export const TOOLS = [
10
12
  {
@@ -217,7 +219,7 @@ export const TOOLS = [
217
219
  },
218
220
  {
219
221
  name: "enable_notifications",
220
- description: "Get notified of incoming messages without waiting for them. Returns immediately: it blocks nothing and starts nothing on its own. It hands you the exact Monitor call to make right after — arming that watcher is what actually delivers notifications, and until you do it no message will reach you. Call this once at the start of a session, before any other work; then just keep working, incoming messages will interrupt you on their own.",
222
+ description: "Get notified of incoming messages without waiting for them. Returns immediately: it blocks nothing and starts nothing on its own. It hands you the exact Monitor call to make right after — arming that watcher is what actually delivers notifications, and until you do it no message will reach you. Call this once at the start of a session, before any other work; then just keep working, incoming messages will interrupt you on their own. The host stops the watcher after 30 minutes at most: re-arm it with the same Monitor call each time it expires.",
221
223
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
222
224
  },
223
225
  {
@@ -522,6 +524,9 @@ export async function callTool(client, name, rawArgs) {
522
524
  `Notifications are already live on mailbox \`${mailbox}\` (watcher armed since ${stats.watcher.since}).`,
523
525
  "Nothing to do: keep working, incoming messages will interrupt you on their own.",
524
526
  "Do not arm a second watcher — you would get every message twice.",
527
+ "Only exception: if the host just told you your watcher expired or stopped, re-arm it",
528
+ `with the same Monitor call (command \`npx -y ${PACKAGE_NAME} --watch ${mailbox}\`,`,
529
+ `timeout_ms ${MONITOR_TIMEOUT_MS}).`,
525
530
  ].join("\n");
526
531
  }
527
532
  const lines = [
@@ -530,11 +535,15 @@ export async function callTool(client, name, rawArgs) {
530
535
  "Last step — call the Monitor tool now, with exactly these arguments:",
531
536
  ` command: npx -y ${PACKAGE_NAME} --watch ${mailbox}`,
532
537
  " description: Incoming messenger messages",
533
- " persistent: true",
538
+ ` timeout_ms: ${MONITOR_TIMEOUT_MS}`,
534
539
  "",
535
540
  "Until that watcher is armed, nothing will reach you: no message will be announced.",
536
541
  "Once it is, keep working normally — each incoming message shows up on its own, even",
537
542
  "while you are idle. One line per message; read it with read_message.",
543
+ "",
544
+ "The host stops every monitor after 30 minutes at most. Each time it tells you this",
545
+ "watcher expired, call Monitor again right away with the same arguments — do not call",
546
+ "enable_notifications again. Skip that and notifications stop for good.",
538
547
  ];
539
548
  if (client.isAttachedElsewhere) {
540
549
  lines.push("", `Note: you are attached to \`${mailbox}\`, not to this session's own mailbox ` +
@@ -102,21 +102,24 @@ const isoDate = z
102
102
  .regex(/^\d{4}-\d{2}-\d{2}(T[\d:.]+Z?)?$/, "expected a date such as 2026-08-01")
103
103
  .refine((value) => !Number.isNaN(Date.parse(value)), "not a real date");
104
104
  const staleSchema = z.object({ before: isoDate });
105
+ const createdBeforeSchema = z.object({ created_before: isoDate });
105
106
  /**
106
107
  * Une suppression désigne ses boîtes, et rappelle à quel titre elles sont supprimables :
107
- * `before`, elles n'ont plus d'activité depuis cette date ; `empty`, rien n'y est jamais
108
- * passé. Les deux critères s'excluent — le serveur revérifie celui qu'on lui donne, il
109
- * doit donc savoir lequel c'est.
108
+ * `before`, elles n'ont plus d'activité depuis cette date ; `created_before`, elles ont
109
+ * été ouvertes avant cette date ; `empty`, rien n'y est jamais passé. Les trois critères
110
+ * s'excluent — le serveur revérifie celui qu'on lui donne, il doit donc savoir lequel
111
+ * c'est, et les deux critères datés ne désignent pas les mêmes boîtes.
110
112
  */
111
113
  const purgeSchema = z
112
114
  .object({
113
115
  before: isoDate.optional(),
116
+ created_before: isoDate.optional(),
114
117
  empty: z.literal(true).optional(),
115
118
  /** Les adresses vues dans l'aperçu : on ne supprime jamais autre chose que ce qui a été montré. */
116
119
  mailboxes: z.array(z.string().trim().min(1)).min(1).max(500),
117
120
  })
118
- .refine((input) => (input.before === undefined) !== (input.empty === undefined), {
119
- message: "provide either `before` (inactive since that date) or `empty: true`, not both",
121
+ .refine((input) => [input.before, input.created_before, input.empty].filter((value) => value !== undefined).length === 1, {
122
+ message: "provide exactly one of `before` (inactive since that date), `created_before` (created before that date) or `empty: true`",
120
123
  });
121
124
  const adminListSchema = z.object({
122
125
  search: z.string().trim().min(1).optional(),
@@ -130,7 +133,10 @@ export function createApp({ store, config }) {
130
133
  // Présence des veilleurs : état de connexion, donc en mémoire du processus.
131
134
  const watchers = new WatcherRegistry();
132
135
  app.disable("x-powered-by");
133
- app.set("trust proxy", false);
136
+ // Le serveur n'écoute qu'en loopback : ce qui l'atteint de l'extérieur passe par un
137
+ // reverse proxy. Sans ce réglage, toutes les requêtes se présentent comme `127.0.0.1`
138
+ // et le quota par adresse n'en distingue aucune.
139
+ app.set("trust proxy", config.trustProxy);
134
140
  app.use(helmet({
135
141
  contentSecurityPolicy: {
136
142
  directives: {
@@ -160,7 +166,9 @@ export function createApp({ store, config }) {
160
166
  const cookieOptions = (req) => ({
161
167
  httpOnly: true,
162
168
  sameSite: "strict",
163
- // `req.secure` n'est vrai derrière un proxy TLS que si `trust proxy` est configuré.
169
+ // Vrai dès que le proxy annonce `X-Forwarded-Proto: https`, ce que `trust proxy` (posé
170
+ // plus haut depuis la configuration) autorise Express à lire. Reste faux en clair sur
171
+ // la machine, où le cookie doit justement circuler.
164
172
  secure: req.secure,
165
173
  path: "/",
166
174
  });
@@ -236,6 +244,16 @@ export function createApp({ store, config }) {
236
244
  const { before } = staleSchema.parse(req.query);
237
245
  res.json({ before, mailboxes: store.staleMailboxes(before) });
238
246
  });
247
+ /**
248
+ * Boîtes ouvertes avant une date : ce que la purge par ancienneté emporterait.
249
+ *
250
+ * Même aperçu obligatoire que les autres nettoyages, mais un critère différent — ici
251
+ * l'activité ne protège rien, seule compte la date d'ouverture de la boîte.
252
+ */
253
+ admin.get("/mailboxes/created-before", (req, res) => {
254
+ const { created_before: createdBefore } = createdBeforeSchema.parse(req.query);
255
+ res.json({ created_before: createdBefore, mailboxes: store.mailboxesCreatedBefore(createdBefore) });
256
+ });
239
257
  /**
240
258
  * Boîtes par lesquelles rien n'est jamais passé. Sans date : une boîte vide l'est
241
259
  * indépendamment de son âge, et un agent dont toutes les boîtes sont vides disparaît
@@ -254,15 +272,29 @@ export function createApp({ store, config }) {
254
272
  */
255
273
  admin.post("/mailboxes/purge", (req, res) => {
256
274
  const input = purgeSchema.parse(req.body ?? {});
257
- const reason = input.empty ? "empty" : `inactive since ${input.before}`;
258
- const eligible = new Set((input.empty ? store.emptyMailboxes() : store.staleMailboxes(input.before)).map((mailbox) => mailbox.id));
275
+ const reason = input.empty
276
+ ? "empty"
277
+ : input.created_before
278
+ ? `older than ${input.created_before}`
279
+ : `inactive since ${input.before}`;
280
+ const candidates = input.empty
281
+ ? store.emptyMailboxes()
282
+ : input.created_before
283
+ ? store.mailboxesCreatedBefore(input.created_before)
284
+ : store.staleMailboxes(input.before);
285
+ const eligible = new Set(candidates.map((mailbox) => mailbox.id));
259
286
  const changed = input.mailboxes.filter((id) => !eligible.has(id));
260
287
  if (changed.length > 0) {
261
288
  throw new MessengerError(`these mailboxes are no longer ${reason}, nothing was deleted: ${changed.join(", ")}`, 409);
262
289
  }
263
290
  const removed = store.deleteMailboxes(input.mailboxes);
264
291
  logger.warn(`admin purge: ${removed.mailboxes} mailbox(es) deleted, ${reason}`);
265
- res.json({ before: input.before ?? null, empty: input.empty ?? false, ...removed });
292
+ res.json({
293
+ before: input.before ?? null,
294
+ created_before: input.created_before ?? null,
295
+ empty: input.empty ?? false,
296
+ ...removed,
297
+ });
266
298
  });
267
299
  /**
268
300
  * Suppression d'une boîte désignée à la main, quel que soit son état.
@@ -502,6 +534,10 @@ function authenticateAgent(config) {
502
534
  res.status(401).json({ error: "invalid or missing bearer token" });
503
535
  return;
504
536
  }
537
+ // Le jeton partagé atteste l'appartenance à la flotte, pas une adresse : `X-Agent-Id`
538
+ // n'est adossé à rien ici, et rien ne cloisonne les boîtes entre elles. C'est le mode
539
+ // « cercle de confiance », annoncé comme tel au démarrage et dans le README ;
540
+ // MESSENGER_AGENT_TOKENS est ce qui lie une adresse à un jeton.
505
541
  }
506
542
  req.agentId = agentId;
507
543
  next();
@@ -43,6 +43,13 @@ export async function runServerMode() {
43
43
  logger.info(`mcp-messenger v${APP_VERSION} listening on ${running.url} (loopback only)`);
44
44
  logger.info(`database: ${config.dbPath}`);
45
45
  logger.info(`auth: ${authMode} — required on every request`);
46
+ // Le jeton partagé prouve qu'on fait partie de la flotte, pas qu'on est telle boîte :
47
+ // `X-Agent-Id` n'est alors adossé à rien. Le dire ici, sinon la ligne au-dessus se lit
48
+ // comme une garantie de cloisonnement qu'elle ne donne pas.
49
+ if (config.agentTokens.size === 0) {
50
+ logger.warn("shared token: no isolation between mailboxes — any holder can act as any address");
51
+ logger.warn("set MESSENGER_AGENT_TOKENS (one token per agent) to compartmentalize");
52
+ }
46
53
  logger.info(`web console: ${running.url}/ui/ (asks for MESSENGER_ADMIN_TOKEN once)`);
47
54
  let shuttingDown = false;
48
55
  const shutdown = (signal) => {
@@ -101,7 +101,9 @@ const el = {
101
101
  purgeClose: document.getElementById("purge-close"),
102
102
  purgeCancel: document.getElementById("purge-cancel"),
103
103
  purgeBefore: document.getElementById("purge-before"),
104
+ purgeCreatedBefore: document.getElementById("purge-created-before"),
104
105
  purgePreview: document.getElementById("purge-preview"),
106
+ purgePreviewCreated: document.getElementById("purge-preview-created"),
105
107
  purgePreviewEmpty: document.getElementById("purge-preview-empty"),
106
108
  purgeResult: document.getElementById("purge-result"),
107
109
  purgeConfirm: document.getElementById("purge-confirm"),
@@ -1303,8 +1305,11 @@ const purge = { mode: null, before: null, mailboxes: [], selected: new Set(), ar
1303
1305
 
1304
1306
  function openPurge() {
1305
1307
  const month = new Date(Date.now() - 30 * 86_400_000);
1308
+ const today = new Date().toISOString().slice(0, 10);
1306
1309
  el.purgeBefore.value = month.toISOString().slice(0, 10);
1307
- el.purgeBefore.max = new Date().toISOString().slice(0, 10);
1310
+ el.purgeBefore.max = today;
1311
+ el.purgeCreatedBefore.value = month.toISOString().slice(0, 10);
1312
+ el.purgeCreatedBefore.max = today;
1308
1313
  resetPurge();
1309
1314
  el.purge.showModal();
1310
1315
  }
@@ -1356,6 +1361,13 @@ async function previewStale() {
1356
1361
  await previewPurge("stale", () => api("/admin/mailboxes/stale", { before }), before);
1357
1362
  }
1358
1363
 
1364
+ /** Boîtes ouvertes avant la date choisie, qu'elles servent encore ou non. */
1365
+ async function previewCreated() {
1366
+ const before = el.purgeCreatedBefore.value;
1367
+ if (!before) return;
1368
+ await previewPurge("created", () => api("/admin/mailboxes/created-before", { created_before: before }), before);
1369
+ }
1370
+
1359
1371
  async function previewPurge(mode, load, before = null) {
1360
1372
  resetPurge();
1361
1373
  try {
@@ -1373,10 +1385,15 @@ async function previewPurge(mode, load, before = null) {
1373
1385
  function renderPurgePreview() {
1374
1386
  const container = document.createDocumentFragment();
1375
1387
  const count = purge.mailboxes.length;
1376
- // Le critère est rappelé sous les yeux de qui va confirmer : les trois suppressions
1377
- // remplissent la même liste, et elles ne portent pas sur les mêmes boîtes.
1378
- const since = ` sans activité depuis le ${formatDay(purge.before)}`;
1379
- const criterion = purge.mode === "empty" ? "vide" : purge.mode === "one" ? "" : since;
1388
+ // Le critère est rappelé sous les yeux de qui va confirmer : les quatre suppressions
1389
+ // remplissent la même liste, et elles ne portent pas sur les mêmes boîtes. Les deux
1390
+ // critères datés se distinguent par leur seule formulation : « depuis » regarde
1391
+ // l'activité, « créée avant » regarde l'ouverture de la boîte.
1392
+ const dated =
1393
+ purge.mode === "created"
1394
+ ? ` créée avant le ${formatDay(purge.before)}`
1395
+ : ` sans activité depuis le ${formatDay(purge.before)}`;
1396
+ const criterion = purge.mode === "empty" ? "vide" : purge.mode === "one" ? "" : dated;
1380
1397
 
1381
1398
  if (count === 0) {
1382
1399
  container.appendChild(node("p", "purge-empty", `Aucune boîte ${criterion.trim()}.`));
@@ -1392,7 +1409,9 @@ function renderPurgePreview() {
1392
1409
  ? "Cette boîte, et tout ce que personne d'autre ne pourrait plus lire :"
1393
1410
  : purge.mode === "empty"
1394
1411
  ? `${plural(count, "boîte vide", "boîtes vides")}.`
1395
- : `${plural(count, "boîte")}${since}.`
1412
+ : purge.mode === "created"
1413
+ ? `${plural(count, "boîte créée", "boîtes créées")} avant le ${formatDay(purge.before)}.`
1414
+ : `${plural(count, "boîte")}${dated}.`
1396
1415
  )
1397
1416
  );
1398
1417
 
@@ -1457,7 +1476,11 @@ async function confirmPurge() {
1457
1476
  ? await remove(`/admin/mailboxes/${encodeURIComponent(deleted[0])}`)
1458
1477
  : await post("/admin/mailboxes/purge", {
1459
1478
  // Le critère repart avec les adresses : c'est lui que le serveur revérifie.
1460
- ...(purge.mode === "empty" ? { empty: true } : { before: purge.before }),
1479
+ ...(purge.mode === "empty"
1480
+ ? { empty: true }
1481
+ : purge.mode === "created"
1482
+ ? { created_before: purge.before }
1483
+ : { before: purge.before }),
1461
1484
  mailboxes: deleted,
1462
1485
  });
1463
1486
  resetPurge();
@@ -1488,7 +1511,9 @@ el.purgeOpen.addEventListener("click", openPurge);
1488
1511
  el.purgeClose.addEventListener("click", () => el.purge.close());
1489
1512
  el.purgeCancel.addEventListener("click", () => el.purge.close());
1490
1513
  el.purgeBefore.addEventListener("change", resetPurge);
1514
+ el.purgeCreatedBefore.addEventListener("change", resetPurge);
1491
1515
  el.purgePreview.addEventListener("click", previewStale);
1516
+ el.purgePreviewCreated.addEventListener("click", previewCreated);
1492
1517
  el.purgePreviewEmpty.addEventListener("click", previewEmpty);
1493
1518
  el.purgeConfirm.addEventListener("click", confirmPurge);
1494
1519
 
@@ -70,7 +70,7 @@
70
70
  </button>
71
71
  </div>
72
72
  <p class="modal-sub">
73
- Deux nettoyages, deux critères. La suppression est définitive dans les deux cas, et emporte les messages que
73
+ Trois nettoyages, trois critères. La suppression est définitive dans tous les cas, et emporte les messages que
74
74
  plus personne ne pourrait lire ; ce que le correspondant a reçu de son côté reste intact. Un agent dont toutes
75
75
  les boîtes disparaissent quitte l'annuaire avec elles.
76
76
  </p>
@@ -94,6 +94,16 @@
94
94
  <button type="button" class="btn" id="purge-preview">Prévisualiser</button>
95
95
  </div>
96
96
 
97
+ <!-- Troisième critère : l'âge de la boîte, que son activité ne rachète pas. -->
98
+ <div class="purge-controls">
99
+ <label class="field">
100
+ <span class="field-label">Boîtes créées avant le</span>
101
+ <span class="field-input"><input type="date" id="purge-created-before" /></span>
102
+ <span class="purge-hint">Sur la seule date d'ouverture : une boîte encore active en fait partie.</span>
103
+ </label>
104
+ <button type="button" class="btn" id="purge-preview-created">Prévisualiser</button>
105
+ </div>
106
+
97
107
  <div class="purge-result" id="purge-result"></div>
98
108
 
99
109
  <div class="modal-foot">
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.27";
1
+ export declare const APP_VERSION = "1.0.29";
@@ -1,2 +1,2 @@
1
1
  // Généré par scripts/generate-version.js — ne pas modifier à la main.
2
- export const APP_VERSION = "1.0.27";
2
+ export const APP_VERSION = "1.0.29";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",