@imenam/mcp-messenger 1.0.6 → 1.0.8

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/.env.example CHANGED
@@ -13,7 +13,8 @@ MESSENGER_PORT=3600
13
13
  # Chemin du fichier SQLite (défaut : <cwd>/data/messenger.db).
14
14
  # MESSENGER_DB_PATH=/var/lib/mcp-messenger/messenger.db
15
15
 
16
- # Jeton exigé sur TOUTE requête entrante (API, /health, console web).
16
+ # Jeton exigé sur TOUTE requête de données (API, /health, données de la console).
17
+ # Seule la page de la console (/ui), qui ne contient aucun secret, est servie sans lui.
17
18
  # OBLIGATOIRE : sans MESSENGER_TOKEN ni MESSENGER_AGENT_TOKENS, le serveur refuse
18
19
  # de démarrer. Générer avec : npx @imenam/mcp-messenger --generate-token
19
20
  MESSENGER_TOKEN=change-me
@@ -23,8 +24,10 @@ MESSENGER_TOKEN=change-me
23
24
  # Format : agent_id:token,agent_id:token
24
25
  # MESSENGER_AGENT_TOKENS=planner:tok-planner,coder:tok-coder
25
26
 
26
- # Jeton de la console web. À défaut, MESSENGER_TOKEN fait office ; il devient
27
- # obligatoire si seul MESSENGER_AGENT_TOKENS est utilisé.
27
+ # Jeton de la console web, saisi une fois dans son formulaire (/ui) : le serveur ouvre
28
+ # alors une session de 12 h dans un cookie HttpOnly, sans jamais y mettre le jeton.
29
+ # À défaut, MESSENGER_TOKEN fait office ; il devient obligatoire si seul
30
+ # MESSENGER_AGENT_TOKENS est utilisé.
28
31
  # MESSENGER_ADMIN_TOKEN=change-me-too
29
32
 
30
33
  # Rate limiting (par IP).
package/README.md CHANGED
@@ -97,7 +97,7 @@ la session MCP démarre quand même et l'enregistrement se fera au premier appel
97
97
 
98
98
  L'agent A appelle `send_message` avec `to: ["coder"]`, l'agent B voit le message via
99
99
  `list_messages` puis `read_message`, et répond avec `reply_to` pour rester dans le fil.
100
- Le fil se nomme au passage (`thread_title` sur `send_message`, ou `set_label` plus tard).
100
+ Le fil se nomme au passage (`thread_name` sur `send_message`, ou `rename_thread` plus tard).
101
101
  `wait_for_message` permet d'attendre une réponse sans faire tourner une boucle d'appels : il ne
102
102
  rend la main que sur un message arrivé *après* le début de l'attente, les non-lus déjà en boîte
103
103
  ne l'interrompent pas.
@@ -177,10 +177,11 @@ superviseur capable de lire les lignes d'un processus peut la brancher. Sans rel
177
177
  |---|---|
178
178
  | `whoami` | Adresse de cette session, serveur connecté, veilleur armé ou non, compteurs par dossier |
179
179
  | `attach_to_mailbox` | Se brancher sur une autre boîte via son adresse ; sans argument, retour à la sienne |
180
- | `set_label` | Nommer sa boîte, ou un fil (`thread_id`), en langue naturelle |
180
+ | `rename_mailbox` | Nommer ou renommer sa boîte (de quoi la session s'occupe) ; le nom de l'agent, posé par la configuration, reste |
181
+ | `rename_thread` | Nommer ou renommer un fil (`thread_id`) ; le nom est partagé par tous ses participants |
181
182
  | `list_threads` | Carte des conversations : nom, participants, non-lus, dernière activité |
182
- | `list_agents` | Annuaire : qui est joignable, du plus récemment actif au plus ancien |
183
- | `send_message` | Envoyer à un ou plusieurs agents (`to`, `cc`, `priority`, `reply_to`, `thread_title`) |
183
+ | `list_agents` | Annuaire : qui est joignable, du plus récemment actif au plus ancien ; la boîte de l'appelant n'y figure pas |
184
+ | `send_message` | Écrire à un agent — un seul destinataire par message (`to`, `priority`, `reply_to`, `thread_name`) |
184
185
  | `list_messages` | Lister la boîte (filtres : dossier, non lus, expéditeur, fil, texte libre) |
185
186
  | `read_message` | Lire un message en entier ; le marque lu par défaut |
186
187
  | `get_thread` | Relire toute une conversation, du plus ancien au plus récent |
@@ -194,12 +195,18 @@ superviseur capable de lire les lignes d'un processus peut la brancher. Sans rel
194
195
 
195
196
  **Noms lisibles** — voir « Nommer les boîtes et les fils » ci-dessous.
196
197
 
198
+ **Un message, deux boîtes** — un échange lie exactement deux agents *distincts* : `to` prend
199
+ une seule adresse, il n'y a ni diffusion ni copie, et une boîte ne peut pas s'écrire à
200
+ elle-même. Pour toucher plusieurs agents, on leur envoie un message chacun, dans son propre
201
+ fil. C'est aussi pourquoi `list_agents` ne montre jamais l'appelant : la seule adresse qu'il
202
+ ne peut pas viser n'a pas à figurer dans son annuaire.
203
+
197
204
  **Dossiers système** — toujours présents, non supprimables : `inbox`, `sent`, `archive`,
198
205
  `trash`. Les dossiers personnalisés sont propres à chaque boîte.
199
206
 
200
207
  **Ce que voit un agent** : uniquement les livraisons de la boîte à laquelle il est attaché. Un message adressé à `coder`
201
208
  est invisible pour tout autre agent, y compris via son identifiant. Supprimer un message ne
202
- touche que sa propre copie : les autres destinataires gardent la leur.
209
+ touche que sa propre copie : l'autre partie garde la sienne.
203
210
 
204
211
  ---
205
212
 
@@ -221,25 +228,58 @@ ont deux boîtes distinctes et ne se volent pas leur courrier.
221
228
  2. Transmets-la à l'agent B, qui l'utilise dans `send_message`.
222
229
 
223
230
  Comme les adresses sont opaques, demande aux agents de nommer leur boîte
224
- (`set_label`) : le libellé apparaît dans `list_agents` et dans la console web, ce
231
+ (`rename_mailbox`) : le nom apparaît dans `list_agents` et dans la console web, ce
225
232
  qui permet de retrouver une adresse perdue.
226
233
 
234
+ ### Deux noms par boîte : l'agent et la boîte
235
+
236
+ Une boîte porte deux identités, qui répondent à deux questions différentes et ne changent
237
+ pas au même rythme :
238
+
239
+ | | D'où vient-il ? | De quoi s'occupe-t-il ? |
240
+ |---|---|---|
241
+ | Champ | `agent_name`, `agent_description` | `display_name`, `description` |
242
+ | Posé par | la configuration (`MESSENGER_AGENT_NAME`, `MESSENGER_AGENT_DESCRIPTION`) | l'agent lui-même, via `rename_mailbox` |
243
+ | Change | jamais depuis une session | librement, au fil du travail |
244
+ | Exemple | `Ai Corrector Dev` | `Correction du parseur de copies` |
245
+
246
+ Le nom de l'agent seul ne distingue pas deux sessions ouvertes sur le même projet ; le nom
247
+ de la boîte seul ne dit pas d'où l'agent écrit. L'annuaire (`list_agents`), `whoami` et la
248
+ console web montrent donc toujours les deux, sous la forme `Agent · Boîte`. Renommer la
249
+ boîte ne touche jamais au nom de l'agent.
250
+
251
+ **Un nom d'agent ne désigne personne.** C'est le piège de ce modèle : plusieurs boîtes
252
+ portent le même `agent_name`, une par session lancée depuis la même configuration. Un agent
253
+ qui croise son propre nom d'agent dans l'annuaire regarde une *autre* session du même
254
+ projet, jamais la sienne — sa boîte, elle, n'y figure pas du tout. Seule l'adresse identifie
255
+ une session. Ce rappel est servi à trois endroits, du plus tôt au plus tard : les
256
+ instructions du serveur MCP (reçues à la connexion, avant le premier outil), la ligne qui
257
+ suit `Agent:` dans `whoami`, et le pied de l'annuaire dans `list_agents`.
258
+
259
+ **Les adresses ne se recopient pas à la main.** Le serveur attache l'expéditeur et le
260
+ destinataire à chaque message, et le lecteur les voit dans l'en-tête. Écrire son adresse
261
+ dans le corps n'ajoute rien et se met à mentir dès qu'elle est erronée ou périmée —
262
+ `send_message` le dit dans sa description et dans celle de `body`.
263
+
227
264
  ### Nommer les boîtes et les fils
228
265
 
229
266
  Les identifiants (`box_…`, `thr_…`) ne disent rien de ce qui se passe. Deux noms en langue
230
- naturelle viennent par-dessus, et un seul outil les pose :
267
+ naturelle viennent par-dessus, un outil pour chacun :
231
268
 
232
269
  ```
233
- set_label(label: "Développement des tests unitaires du parser")
234
- → nomme la boîte de la session
270
+ rename_mailbox(name: "Développement des tests unitaires du parser")
271
+ → nomme la boîte de la session (le nom de l'agent reste)
235
272
 
236
- set_label(thread_id: "thr_…", label: "Le test échoue sur un bug applicatif")
273
+ rename_thread(thread_id: "thr_…", name: "Le test échoue sur un bug applicatif")
237
274
  → nomme le fil
238
275
  ```
239
276
 
240
- Le plus simple reste de nommer un fil au moment où on l'ouvre, via `thread_title` sur
277
+ Le plus simple reste de nommer un fil au moment où on l'ouvre, via `thread_name` sur
241
278
  `send_message` : l'agent qui écrit le premier message sait déjà de quoi il parle.
242
279
 
280
+ L'ancien outil `set_label`, qui cumulait les deux rôles, reste accepté sans être publié :
281
+ une session lancée avant la mise à jour peut encore l'appeler.
282
+
243
283
  Un fil n'a **qu'un seul nom, partagé par tous ses participants** — ils discutent du même
244
284
  sujet, ils doivent lire le même intitulé. N'importe quel participant peut le renommer, et
245
285
  le dernier a le dernier mot ; le serveur retient qui (`named_by`). Un agent extérieur au
@@ -325,7 +365,7 @@ Toutes les variables sont documentées dans [`.env.example`](.env.example). L'es
325
365
  | `MESSENGER_AGENT_ID` | — | Adresse **fixe**. Vide (recommandé) = une boîte par session |
326
366
  | `MESSENGER_SERVER_URL` | ✅ | `http://` ou `https://`, local ou distant |
327
367
  | `MESSENGER_TOKEN` | ✅ | Jeton présenté au serveur, sur chaque appel |
328
- | `MESSENGER_AGENT_NAME` | — | Nom affiché dans l'annuaire |
368
+ | `MESSENGER_AGENT_NAME` | — | Nom de l'agent (le projet, typiquement), affiché à côté du nom de la boîte |
329
369
  | `MESSENGER_AGENT_DESCRIPTION` | — | À quoi sert cet agent, pour ses correspondants |
330
370
  | `MESSENGER_TIMEOUT_MS` | `15000` | Délai maximal d'un appel HTTP |
331
371
  | `MESSENGER_STATE_DIR` | — | Répertoire des relais vers le veilleur (défaut : `~/.mcp-messenger`) |
@@ -344,7 +384,7 @@ la console (cookie), qui elle n'ouvre rien d'autre.
344
384
  |---|---|---|
345
385
  | `GET` | `/health` | État du service |
346
386
  | `POST` | `/api/agents/register` | S'inscrire à l'annuaire (`fresh: true` = exige une adresse neuve) |
347
- | `GET` | `/api/agents` | Annuaire des boîtes |
387
+ | `GET` | `/api/agents` | Annuaire des boîtes, sauf celle de l'appelant |
348
388
  | `GET` | `/api/agents/:id` | Une boîte, ou 404 |
349
389
  | `PATCH` | `/api/agents/me` | Renommer la boîte courante |
350
390
  | `GET` | `/api/mailbox` | Compteurs de la boîte, et présence d'un veilleur (`watcher`) |
@@ -44,7 +44,7 @@ const REQUIRED_VARS = [
44
44
  ];
45
45
  const OPTIONAL_VARS = [
46
46
  ["MESSENGER_AGENT_ID", "adresse fixe : à laisser vide pour une boîte par session"],
47
- ["MESSENGER_AGENT_NAME", "nom affiché dans l'annuaire des agents"],
47
+ ["MESSENGER_AGENT_NAME", "nom de l'agent (le projet, typiquement) ; distinct du nom de la boîte, que l'agent pose lui-même"],
48
48
  ["MESSENGER_AGENT_DESCRIPTION", "à quoi sert cet agent, pour ses correspondants"],
49
49
  ["MESSENGER_TIMEOUT_MS", "délai maximal d'un appel HTTP (défaut : 15000)"],
50
50
  ];
@@ -44,8 +44,8 @@ export function resolveWatchConfig(mailbox, env = process.env) {
44
44
  agentId: target,
45
45
  serverUrl: serverUrl.replace(/\/+$/, ""),
46
46
  token: handoff?.token ?? env.MESSENGER_TOKEN?.trim() ?? null,
47
- displayName: null,
48
- description: null,
47
+ agentName: null,
48
+ agentDescription: null,
49
49
  timeoutMs: Number(env.MESSENGER_TIMEOUT_MS) || 15_000,
50
50
  },
51
51
  };
@@ -52,7 +52,7 @@ export declare class MessengerClient {
52
52
  register(options?: {
53
53
  fresh?: boolean;
54
54
  }): Promise<Agent>;
55
- /** Renomme la boîte courante, pour la rendre reconnaissable dans l'annuaire. */
55
+ /** Renomme la boîte courante (pas l'agent), pour dire de quoi cette session s'occupe. */
56
56
  updateProfile(profile: {
57
57
  display_name?: string | null;
58
58
  description?: string | null;
@@ -61,8 +61,7 @@ export declare class MessengerClient {
61
61
  listAgents(): Promise<AgentDirectoryEntry[]>;
62
62
  stats(): Promise<MailboxStats>;
63
63
  sendMessage(input: {
64
- to: string[];
65
- cc?: string[];
64
+ to: string;
66
65
  subject: string;
67
66
  body: string;
68
67
  priority?: Priority;
@@ -68,14 +68,14 @@ export class MessengerClient {
68
68
  async register(options = {}) {
69
69
  const { agent } = await this.request("POST", "/api/agents/register", {
70
70
  body: {
71
- display_name: this.config.displayName,
72
- description: this.config.description,
71
+ agent_name: this.config.agentName,
72
+ agent_description: this.config.agentDescription,
73
73
  fresh: options.fresh ?? false,
74
74
  },
75
75
  });
76
76
  return agent;
77
77
  }
78
- /** Renomme la boîte courante, pour la rendre reconnaissable dans l'annuaire. */
78
+ /** Renomme la boîte courante (pas l'agent), pour dire de quoi cette session s'occupe. */
79
79
  async updateProfile(profile) {
80
80
  const { agent } = await this.request("PATCH", "/api/agents/me", { body: profile });
81
81
  return agent;
@@ -27,8 +27,9 @@ export interface AgentConfig {
27
27
  agentId: string | null;
28
28
  serverUrl: string;
29
29
  token: string | null;
30
- displayName: string | null;
31
- description: string | null;
30
+ /** Identité de l'agent, stable : d'où il écrit (le projet). Distincte du nom de la boîte. */
31
+ agentName: string | null;
32
+ agentDescription: string | null;
32
33
  timeoutMs: number;
33
34
  }
34
35
  export declare function isLoopbackHost(host: string): boolean;
@@ -74,8 +74,8 @@ export function loadAgentConfig(env = process.env) {
74
74
  agentId,
75
75
  serverUrl: rawUrl.replace(/\/+$/, ""),
76
76
  token: nonEmpty(env.MESSENGER_TOKEN),
77
- displayName: nonEmpty(env.MESSENGER_AGENT_NAME),
78
- description: nonEmpty(env.MESSENGER_AGENT_DESCRIPTION),
77
+ agentName: nonEmpty(env.MESSENGER_AGENT_NAME),
78
+ agentDescription: nonEmpty(env.MESSENGER_AGENT_DESCRIPTION),
79
79
  timeoutMs: parseIntEnv(env.MESSENGER_TIMEOUT_MS, 15_000, "MESSENGER_TIMEOUT_MS"),
80
80
  };
81
81
  }
@@ -5,6 +5,15 @@ export declare class MessengerError extends Error {
5
5
  readonly status: number;
6
6
  constructor(message: string, status?: number);
7
7
  }
8
+ export interface EnsureAgentOptions {
9
+ registered?: boolean;
10
+ /** Identité de l'agent : d'où il vient. Posée par la configuration. */
11
+ agent_name?: string | null;
12
+ agent_description?: string | null;
13
+ /** Nom de la boîte : de quoi cette session s'occupe. Posé par l'agent lui-même. */
14
+ display_name?: string | null;
15
+ description?: string | null;
16
+ }
8
17
  export interface StoreEvents {
9
18
  /** Émis pour chaque destinataire d'un message, y compris la copie « sent » de l'expéditeur. */
10
19
  message: [{
@@ -26,16 +35,21 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
26
35
  constructor(dbPath: string);
27
36
  private migrate;
28
37
  close(): void;
29
- /** Crée la fiche au besoin. `registered: false` = agent connu seulement comme destinataire. */
30
- ensureAgent(id: string, opts?: {
31
- registered?: boolean;
32
- display_name?: string | null;
33
- description?: string | null;
34
- }): Agent;
38
+ /**
39
+ * Crée la fiche au besoin. `registered: false` = agent connu seulement comme destinataire.
40
+ * Chaque champ omis (ou null) laisse la valeur en place : l'enregistrement pose l'identité
41
+ * de l'agent sans toucher au nom de la boîte, et `rename_mailbox` fait l'inverse.
42
+ */
43
+ ensureAgent(id: string, opts?: EnsureAgentOptions): Agent;
35
44
  touchAgent(id: string): void;
36
45
  getAgent(id: string): Agent | null;
37
- /** Annuaire classé par activité décroissante : ce qui bouge est en tête, l'id ne trie rien. */
38
- listAgents(): AgentDirectoryEntry[];
46
+ /**
47
+ * Annuaire classé par activité décroissante : ce qui bouge est en tête, l'id ne trie rien.
48
+ *
49
+ * `exclude` retire une adresse du résultat : l'annuaire servi à un agent ne lui montre pas
50
+ * sa propre boîte, la seule à laquelle il ne peut pas écrire.
51
+ */
52
+ listAgents(exclude?: string): AgentDirectoryEntry[];
39
53
  sendMessage(input: SendMessageInput): SendMessageResult;
40
54
  listMessages(agentId: string, opts?: ListMessagesOptions): MessageSummary[];
41
55
  getMessage(agentId: string, messageId: string, markRead?: boolean): Message;
@@ -103,6 +117,7 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
103
117
  /** Filtre les ids fournis sur ceux réellement livrés à cet agent (isolation des boîtes). */
104
118
  private ownedMessageIds;
105
119
  private assertFolderExists;
120
+ /** Destinataire de chaque message demandé, par identifiant de message. */
106
121
  private recipientsFor;
107
122
  private toSummary;
108
123
  private toMessage;
@@ -52,9 +52,11 @@ export class MessageStore extends EventEmitter {
52
52
  migrate() {
53
53
  this.db.exec(`
54
54
  CREATE TABLE IF NOT EXISTS agents (
55
- id TEXT PRIMARY KEY,
56
- display_name TEXT,
57
- description TEXT,
55
+ id TEXT PRIMARY KEY,
56
+ agent_name TEXT,
57
+ agent_description TEXT,
58
+ display_name TEXT,
59
+ description TEXT,
58
60
  created_at TEXT NOT NULL,
59
61
  last_seen_at TEXT,
60
62
  registered INTEGER NOT NULL DEFAULT 0
@@ -103,6 +105,21 @@ export class MessageStore extends EventEmitter {
103
105
  CREATE INDEX IF NOT EXISTS idx_deliveries_message ON deliveries (message_id);
104
106
  CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, created_at);
105
107
  `);
108
+ // Bases antérieures à la séparation agent / boîte : `display_name` y tenait les deux
109
+ // rôles, et venait presque toujours de la configuration (le nom du projet). On le
110
+ // déplace vers l'identité de l'agent ; la boîte repart sans nom, à poser par l'agent.
111
+ const agentColumns = this.db.prepare("PRAGMA table_info(agents)").all().map((c) => c.name);
112
+ if (!agentColumns.includes("agent_name")) {
113
+ this.db.exec(`
114
+ ALTER TABLE agents ADD COLUMN agent_name TEXT;
115
+ ALTER TABLE agents ADD COLUMN agent_description TEXT;
116
+ UPDATE agents
117
+ SET agent_name = display_name,
118
+ agent_description = description,
119
+ display_name = NULL,
120
+ description = NULL;
121
+ `);
122
+ }
106
123
  // Bases antérieures aux fils nommés : chaque fil déjà présent reçoit sa ligne, sans
107
124
  // titre. L'affichage retombe alors sur le sujet d'ouverture, comme avant.
108
125
  this.db.exec(`
@@ -117,25 +134,31 @@ export class MessageStore extends EventEmitter {
117
134
  this.db.close();
118
135
  }
119
136
  // --- Agents -------------------------------------------------------------
120
- /** Crée la fiche au besoin. `registered: false` = agent connu seulement comme destinataire. */
137
+ /**
138
+ * Crée la fiche au besoin. `registered: false` = agent connu seulement comme destinataire.
139
+ * Chaque champ omis (ou null) laisse la valeur en place : l'enregistrement pose l'identité
140
+ * de l'agent sans toucher au nom de la boîte, et `rename_mailbox` fait l'inverse.
141
+ */
121
142
  ensureAgent(id, opts = {}) {
122
143
  const now = new Date().toISOString();
123
144
  const existing = this.db.prepare("SELECT id FROM agents WHERE id = ?").get(id);
124
145
  if (!existing) {
125
146
  this.db
126
- .prepare(`INSERT INTO agents (id, display_name, description, created_at, last_seen_at, registered)
127
- VALUES (?, ?, ?, ?, ?, ?)`)
128
- .run(id, opts.display_name ?? null, opts.description ?? null, now, opts.registered ? now : null, opts.registered ? 1 : 0);
147
+ .prepare(`INSERT INTO agents (id, agent_name, agent_description, display_name, description, created_at, last_seen_at, registered)
148
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
149
+ .run(id, opts.agent_name ?? null, opts.agent_description ?? null, opts.display_name ?? null, opts.description ?? null, now, opts.registered ? now : null, opts.registered ? 1 : 0);
129
150
  }
130
151
  else {
131
152
  this.db
132
153
  .prepare(`UPDATE agents
133
- SET display_name = COALESCE(?, display_name),
134
- description = COALESCE(?, description),
135
- registered = MAX(registered, ?),
136
- last_seen_at = CASE WHEN ? = 1 THEN ? ELSE last_seen_at END
154
+ SET agent_name = COALESCE(?, agent_name),
155
+ agent_description = COALESCE(?, agent_description),
156
+ display_name = COALESCE(?, display_name),
157
+ description = COALESCE(?, description),
158
+ registered = MAX(registered, ?),
159
+ last_seen_at = CASE WHEN ? = 1 THEN ? ELSE last_seen_at END
137
160
  WHERE id = ?`)
138
- .run(opts.display_name ?? null, opts.description ?? null, opts.registered ? 1 : 0, opts.registered ? 1 : 0, now, id);
161
+ .run(opts.agent_name ?? null, opts.agent_description ?? null, opts.display_name ?? null, opts.description ?? null, opts.registered ? 1 : 0, opts.registered ? 1 : 0, now, id);
139
162
  }
140
163
  return this.getAgent(id);
141
164
  }
@@ -146,13 +169,20 @@ export class MessageStore extends EventEmitter {
146
169
  const row = this.db.prepare("SELECT * FROM agents WHERE id = ?").get(id);
147
170
  return row ? { ...row, registered: Boolean(row.registered) } : null;
148
171
  }
149
- /** Annuaire classé par activité décroissante : ce qui bouge est en tête, l'id ne trie rien. */
150
- listAgents() {
172
+ /**
173
+ * Annuaire classé par activité décroissante : ce qui bouge est en tête, l'id ne trie rien.
174
+ *
175
+ * `exclude` retire une adresse du résultat : l'annuaire servi à un agent ne lui montre pas
176
+ * sa propre boîte, la seule à laquelle il ne peut pas écrire.
177
+ */
178
+ listAgents(exclude) {
179
+ const excluded = exclude ? normalizeAgentId(exclude) : "";
151
180
  const rows = this.db
152
181
  .prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at
153
182
  FROM agents a
183
+ WHERE a.id != ?
154
184
  ORDER BY last_activity_at DESC, a.id`)
155
- .all();
185
+ .all(excluded);
156
186
  return rows.map((r) => ({
157
187
  ...r,
158
188
  registered: Boolean(r.registered),
@@ -169,11 +199,14 @@ export class MessageStore extends EventEmitter {
169
199
  const from = normalizeAgentId(input.from);
170
200
  if (!from)
171
201
  throw new MessengerError("sender agent id is required");
172
- const to = dedupe(input.to.map(normalizeAgentId).filter(Boolean));
173
- // Un même agent ne peut pas être à la fois destinataire principal et en copie.
174
- const cc = dedupe((input.cc ?? []).map(normalizeAgentId).filter(Boolean)).filter((id) => !to.includes(id));
175
- if (to.length === 0)
176
- throw new MessengerError("at least one recipient is required in `to`");
202
+ // Un message va d'une boîte à une seule autre : pas de diffusion, pas de copie.
203
+ const to = normalizeAgentId(input.to);
204
+ if (!to)
205
+ throw new MessengerError("a recipient is required in `to`");
206
+ // Une boîte ne se parle pas à elle-même : la copie « sent » et la copie « inbox »
207
+ // désigneraient le même agent, et le fil n'aurait qu'un seul participant.
208
+ if (to === from)
209
+ throw new MessengerError("an agent cannot send a message to itself");
177
210
  const title = normalizeLabel(input.thread_title, THREAD_TITLE_MAX, "thread_title");
178
211
  const threadDescription = normalizeLabel(input.thread_description, THREAD_DESCRIPTION_MAX, "thread_description");
179
212
  let threadId;
@@ -191,18 +224,16 @@ export class MessageStore extends EventEmitter {
191
224
  const messageId = newId("msg");
192
225
  const now = new Date().toISOString();
193
226
  const priority = input.priority ?? "normal";
194
- const unknownRecipients = [...to, ...cc].filter((id) => !this.getAgent(id)?.registered);
227
+ const unknownRecipient = !this.getAgent(to)?.registered;
195
228
  const deliveries = [
196
- ...to.map((agent_id) => ({ agent_id, role: "to", folder: "inbox", read_at: null })),
197
- ...cc.map((agent_id) => ({ agent_id, role: "cc", folder: "inbox", read_at: null })),
229
+ { agent_id: to, role: "to", folder: "inbox", read_at: null },
198
230
  { agent_id: from, role: "sender", folder: "sent", read_at: now },
199
231
  ];
200
232
  this.db.transaction(() => {
201
233
  this.ensureAgent(from);
202
234
  // Écrire est le signe d'activité le plus fiable : l'annuaire s'appuie dessus.
203
235
  this.touchAgent(from);
204
- for (const id of [...to, ...cc])
205
- this.ensureAgent(id);
236
+ this.ensureAgent(to);
206
237
  // Le fil naît avec son premier message ; sur une réponse, la ligne existe déjà et
207
238
  // n'est touchée que si l'expéditeur fournit un nouveau titre.
208
239
  this.db
@@ -231,8 +262,8 @@ export class MessageStore extends EventEmitter {
231
262
  thread_id: threadId,
232
263
  thread_title: this.getThreadInfo(threadId)?.title ?? null,
233
264
  created_at: now,
234
- delivered_to: [...to, ...cc],
235
- unknown_recipients: unknownRecipients,
265
+ delivered_to: to,
266
+ unknown_recipient: unknownRecipient,
236
267
  };
237
268
  }
238
269
  // --- Lecture ------------------------------------------------------------
@@ -282,7 +313,7 @@ export class MessageStore extends EventEmitter {
282
313
  JOIN messages m ON m.id = d.message_id
283
314
  ${THREAD_JOIN}
284
315
  WHERE d.agent_id = ? AND d.message_id = ?
285
- ORDER BY CASE d.role WHEN 'to' THEN 0 WHEN 'cc' THEN 1 ELSE 2 END
316
+ ORDER BY CASE d.role WHEN 'to' THEN 0 ELSE 1 END
286
317
  LIMIT 1`)
287
318
  .get(agentId, messageId);
288
319
  if (!row)
@@ -628,6 +659,7 @@ export class MessageStore extends EventEmitter {
628
659
  if (!row)
629
660
  throw new MessengerError(`unknown folder: ${folder}`, 404);
630
661
  }
662
+ /** Destinataire de chaque message demandé, par identifiant de message. */
631
663
  recipientsFor(messageIds) {
632
664
  const map = new Map();
633
665
  const ids = dedupe(messageIds);
@@ -635,29 +667,20 @@ export class MessageStore extends EventEmitter {
635
667
  return map;
636
668
  const placeholders = ids.map(() => "?").join(",");
637
669
  const rows = this.db
638
- .prepare(`SELECT message_id, agent_id, role FROM deliveries
639
- WHERE message_id IN (${placeholders}) AND role != 'sender'
640
- ORDER BY agent_id`)
670
+ .prepare(`SELECT message_id, agent_id FROM deliveries
671
+ WHERE message_id IN (${placeholders}) AND role = 'to'`)
641
672
  .all(...ids);
642
- for (const row of rows) {
643
- const entry = map.get(row.message_id) ?? { to: [], cc: [] };
644
- if (row.role === "to")
645
- entry.to.push(row.agent_id);
646
- else if (row.role === "cc")
647
- entry.cc.push(row.agent_id);
648
- map.set(row.message_id, entry);
649
- }
673
+ for (const row of rows)
674
+ map.set(row.message_id, row.agent_id);
650
675
  return map;
651
676
  }
652
677
  toSummary(row, recipients) {
653
- const rcpt = recipients.get(row.message_id) ?? { to: [], cc: [] };
654
678
  return {
655
679
  message_id: row.message_id,
656
680
  thread_id: row.thread_id,
657
681
  thread_title: row.thread_title,
658
682
  from: row.sender_id,
659
- to: rcpt.to,
660
- cc: rcpt.cc,
683
+ to: recipients.get(row.message_id) ?? "",
661
684
  subject: row.subject,
662
685
  preview: preview(row.body),
663
686
  priority: row.priority,
@@ -3,11 +3,22 @@ export declare const SYSTEM_FOLDERS: readonly ["inbox", "sent", "archive", "tras
3
3
  export type SystemFolder = (typeof SYSTEM_FOLDERS)[number];
4
4
  export declare const PRIORITIES: readonly ["low", "normal", "high"];
5
5
  export type Priority = (typeof PRIORITIES)[number];
6
- /** Rôle d'un agent vis-à-vis d'un message : destinataire, copie, ou expéditeur. */
7
- export declare const DELIVERY_ROLES: readonly ["to", "cc", "sender"];
6
+ /** Rôle d'un agent vis-à-vis d'un message : destinataire ou expéditeur. */
7
+ export declare const DELIVERY_ROLES: readonly ["to", "sender"];
8
8
  export type DeliveryRole = (typeof DELIVERY_ROLES)[number];
9
+ /**
10
+ * Une boîte porte deux identités qui ne bougent pas au même rythme :
11
+ * - l'agent (`agent_name`, `agent_description`) : d'où il vient, typiquement le projet.
12
+ * Posé par la configuration à l'enregistrement, stable pour toute la vie de la boîte.
13
+ * - la boîte (`display_name`, `description`) : de quoi cette session s'occupe. Vide au
14
+ * départ, posé par l'agent lui-même (`rename_mailbox`), et libre de changer.
15
+ * Le nom de l'agent seul ne distingue pas deux sessions du même projet ; le nom de la boîte
16
+ * seul ne dit pas où l'agent travaille. La console et l'annuaire montrent les deux.
17
+ */
9
18
  export interface Agent {
10
19
  id: string;
20
+ agent_name: string | null;
21
+ agent_description: string | null;
11
22
  display_name: string | null;
12
23
  description: string | null;
13
24
  created_at: string;
@@ -26,8 +37,8 @@ export interface AgentDirectoryEntry extends Agent {
26
37
  }
27
38
  export interface SendMessageInput {
28
39
  from: string;
29
- to: string[];
30
- cc?: string[];
40
+ /** Un échange lie exactement deux boîtes : un seul destinataire par message. */
41
+ to: string;
31
42
  subject: string;
32
43
  body: string;
33
44
  priority?: Priority;
@@ -67,9 +78,9 @@ export interface SendMessageResult {
67
78
  /** Titre du fil après envoi, null s'il n'a jamais été nommé. */
68
79
  thread_title: string | null;
69
80
  created_at: string;
70
- delivered_to: string[];
71
- /** Destinataires jamais enregistrés sur ce serveur — le message les attend malgré tout. */
72
- unknown_recipients: string[];
81
+ delivered_to: string;
82
+ /** Destinataire jamais enregistré sur ce serveur — le message l'attend malgré tout. */
83
+ unknown_recipient: boolean;
73
84
  }
74
85
  export interface MessageSummary {
75
86
  message_id: string;
@@ -77,8 +88,7 @@ export interface MessageSummary {
77
88
  /** Titre du fil, null tant que personne ne l'a nommé — le `subject` fait alors office. */
78
89
  thread_title: string | null;
79
90
  from: string;
80
- to: string[];
81
- cc: string[];
91
+ to: string;
82
92
  subject: string;
83
93
  preview: string;
84
94
  priority: Priority;
@@ -1,5 +1,5 @@
1
1
  /** Dossiers présents pour tout agent : ils ne peuvent être ni créés ni supprimés. */
2
2
  export const SYSTEM_FOLDERS = ["inbox", "sent", "archive", "trash"];
3
3
  export const PRIORITIES = ["low", "normal", "high"];
4
- /** Rôle d'un agent vis-à-vis d'un message : destinataire, copie, ou expéditeur. */
5
- export const DELIVERY_ROLES = ["to", "cc", "sender"];
4
+ /** Rôle d'un agent vis-à-vis d'un message : destinataire ou expéditeur. */
5
+ export const DELIVERY_ROLES = ["to", "sender"];
@@ -8,9 +8,29 @@ import { newId } from "../core/ids.js";
8
8
  import { logger } from "../logger.js";
9
9
  import { APP_VERSION } from "../version.js";
10
10
  import { TOOLS, callTool } from "./tools.js";
11
+ /**
12
+ * Ce que l'hôte reçoit à la poignée de main, avant le moindre appel d'outil.
13
+ *
14
+ * Deux confusions coûtent cher et n'apparaissent qu'au moment d'écrire, trop tard : croire
15
+ * que le nom d'agent désigne une session (il est partagé par toutes celles du projet), et
16
+ * recopier une adresse dans le corps d'un message (le serveur la pose déjà, la copie ment
17
+ * dès qu'elle vieillit). Les dire ici, c'est les dire avant l'erreur.
18
+ */
19
+ const INSTRUCTIONS = `mcp-messenger gives this session a mailbox and lets it exchange messages with other agent sessions.
20
+
21
+ Who you are, and who the others are — read this before writing to anyone:
22
+ - A session is identified by its mailbox address, and by nothing else. \`whoami\` gives you yours.
23
+ - 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.
24
+ - So several mailboxes in \`list_agents\` can carry the same agent name as yours. Recognising your own agent name there does not mean you are looking at yourself — it is another session of the same project, a different agent, with its own mailbox. Compare addresses, never names.
25
+ - Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
26
+
27
+ Writing:
28
+ - One message goes to exactly one agent, and never to yourself. To reach several agents, send each one its own message.
29
+ - 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.
30
+ - Call \`enable_notifications\` once at the start of the session and arm the watcher it hands you. Until then, no incoming message reaches you.`;
11
31
  /** Serveur MCP prêt à être branché sur n'importe quel transport (stdio en production, mémoire en test). */
12
32
  export function createMcpServer(client) {
13
- const server = new Server({ name: "mcp-messenger", version: APP_VERSION }, { capabilities: { tools: {} } });
33
+ const server = new Server({ name: "mcp-messenger", version: APP_VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
14
34
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
15
35
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
16
36
  const { name, arguments: args } = request.params;