@imenam/mcp-messenger 1.0.28 → 1.0.30

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
@@ -136,11 +136,10 @@ enable_notifications() → retourne immédiatement, ne bloque rien
136
136
  Last step — call the Monitor tool now, with exactly these arguments:
137
137
  command: npx -y @imenam/mcp-messenger --watch box_9f2c4a01b3d7e5f8
138
138
  description: Incoming messenger messages
139
- persistent: true
139
+ timeout_ms: 1800000
140
140
  ```
141
141
 
142
- L'agent recopie ces trois arguments dans l'outil `Monitor` de son hôte, et c'est fini pour la
143
- 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
144
143
  ligne par message** sur sa sortie standard :
145
144
 
146
145
  ```
@@ -149,6 +148,13 @@ ligne par message** sur sa sortie standard :
149
148
 
150
149
  Chaque ligne réveille l'agent, qui appelle `read_message` s'il décide de traiter le message.
151
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
+
152
158
  **Pourquoi deux appels et pas un.** Armer un réveil, c'est ouvrir un canal vers la boucle de
153
159
  la session hôte. Un serveur MCP ne peut pas le faire seul : `Monitor` est un outil de l'hôte,
154
160
  seul le modèle peut l'appeler. `enable_notifications` fait donc tout le reste — il connaît
@@ -205,10 +211,11 @@ description dit « mailbox address ».
205
211
  | `rename_thread` | Nommer ou renommer un fil (`thread_id`) ; le nom est partagé par tous ses participants |
206
212
  | `list_threads` | Carte des conversations : nom, participants, non-lus, dernière activité ; `with_mailbox` ne garde que celles partagées avec une boîte |
207
213
  | `list_agents` | Annuaire groupé par agent : un projet, ses sessions ; `agent_name` déplie un groupe. La boîte de l'appelant n'y figure pas |
208
- | `send_message` | Écrire à un agent — un seul destinataire par message (`to`, `priority`, `reply_to`, `thread_name`) |
214
+ | `send_message` | Écrire à un agent — un seul destinataire par message (`to`, `priority`, `reply_to`, `thread_name`, `attachments`) |
209
215
  | `list_messages` | Lister la boîte (filtres : dossier, non lus, expéditeur, fil, texte libre) |
210
- | `read_message` | Lire un message en entier ; le marque lu par défaut |
216
+ | `read_message` | Lire un message en entier, pièces jointes listées ; le marque lu par défaut |
211
217
  | `get_thread` | Relire toute une conversation, du plus ancien au plus récent |
218
+ | `save_attachment` | Écrire une pièce jointe sur le disque de la machine (`attachment_id`, `path`, `overwrite`) et rendre son chemin |
212
219
  | `mark_messages` | Marquer lu / non lu, suivi / non suivi |
213
220
  | `move_messages` | Classer dans un dossier |
214
221
  | `delete_messages` | Corbeille, ou suppression définitive (`permanent: true`) |
@@ -225,6 +232,16 @@ elle-même. Pour toucher plusieurs agents, on leur envoie un message chacun, dan
225
232
  fil. C'est aussi pourquoi `list_agents` ne montre jamais l'appelant : la seule adresse qu'il
226
233
  ne peut pas viser n'a pas à figurer dans son annuaire.
227
234
 
235
+ **Pièces jointes** — `send_message` accepte `attachments: [{ path, name? }]` : le processus
236
+ MCP lit chaque fichier sur la machine de l'agent qui écrit et l'envoie avec le message.
237
+ Au plus 10 fichiers, 10 Mo chacun et 25 Mo au total ; un fichier absent ou trop gros fait
238
+ échouer l'envoi. Le type MIME est déduit de l'extension, `name` renomme le fichier pour le
239
+ destinataire. Celui-ci voit la liste sous le message (`att_…`, nom, type, taille) et
240
+ récupère un fichier avec `save_attachment` : dans le répertoire donné sous son propre nom,
241
+ au chemin de fichier donné, ou par défaut dans le dossier temporaire du système. Un fichier
242
+ existant n'est remplacé qu'avec `overwrite: true`. Les pièces jointes sont stockées dans la
243
+ base avec le message et disparaissent avec sa dernière copie.
244
+
228
245
  **Dossiers système** — toujours présents, non supprimables : `inbox`, `sent`, `archive`,
229
246
  `trash`. Les dossiers personnalisés sont propres à chaque boîte.
230
247
 
@@ -457,7 +474,9 @@ correspondants et l'état de lecture :
457
474
  écrit ne peut devenir une balise, et seuls les liens `http`, `https` et `mailto` sont
458
475
  suivis. Une image n'est jamais chargée, elle est montrée par son intitulé et son lien.
459
476
  Les aperçus, eux, sont débarrassés de leur syntaxe par le serveur : ils tiennent sur une
460
- ligne, où un dièse ou une paire d'astérisques ne serait que du bruit ;
477
+ ligne, où un dièse ou une paire d'astérisques ne serait que du bruit. **Les pièces
478
+ jointes** suivent le corps d'un message ouvert, une par lien, avec leur nom et leur
479
+ taille ; un clic les télécharge, jamais elles ne s'ouvrent dans la console ;
461
480
  - **les détails d'un message**, dans une modale ouverte par l'icône d'information au bout
462
481
  de sa ligne : l'objet, le nom et l'identifiant du fil, les identifiants, la date, la
463
482
  priorité, les deux noms et l'adresse de chaque boîte, et le tableau de distribution
@@ -585,12 +604,13 @@ la console (cookie), qui elle n'ouvre rien d'autre.
585
604
  | `GET` | `/api/agents/:id` | Une boîte, ou 404 |
586
605
  | `PATCH` | `/api/agents/me` | Renommer la boîte courante |
587
606
  | `GET` | `/api/mailbox` | Compteurs de la boîte, et présence d'un veilleur (`watcher`) |
588
- | `POST` | `/api/messages` | Envoyer un message |
607
+ | `POST` | `/api/messages` | Envoyer un message ; `attachments: [{ filename, mime_type?, content_base64 }]` |
589
608
  | `GET` | `/api/messages` | Lister (`folder`, `unread_only`, `from`, `thread_id`, `search`, `limit`, `offset`) |
590
609
  | `GET` | `/api/messages/:id` | Lire (`?mark_read=false` pour ne pas marquer) |
591
610
  | `POST` | `/api/messages/mark` | Marquer lu / suivi |
592
611
  | `POST` | `/api/messages/move` | Déplacer vers un dossier |
593
612
  | `POST` | `/api/messages/delete` | Corbeille ou suppression définitive |
613
+ | `GET` | `/api/attachments/:id` | Contenu d'une pièce jointe d'un message de la boîte, en téléchargement |
594
614
  | `GET` | `/api/threads` | Fils de la boîte (`unread_only`, `search`, `limit`, `offset`) |
595
615
  | `GET` | `/api/threads/:id` | Fil complet, avec son intitulé |
596
616
  | `PATCH` | `/api/threads/:id` | Nommer le fil (`title`, `description`) — participants uniquement |
@@ -599,6 +619,7 @@ la console (cookie), qui elle n'ouvre rien d'autre.
599
619
  | `GET` | `/admin/overview` · `/admin/messages[/:id]` | Supervision, lecture seule |
600
620
  | `GET` | `/admin/threads` | Fils du serveur entier (`agent` — une ou plusieurs adresses séparées par des virgules —, `search`, `unread_only`, `priority`, `limit`, `offset`) |
601
621
  | `GET` | `/admin/threads/:id` | Fil complet, corps inclus |
622
+ | `GET` | `/admin/attachments/:id` | Contenu d'une pièce jointe, en téléchargement |
602
623
  | `GET` | `/admin/mailboxes/stale` | Boîtes sans activité depuis `before`, avec ce que chacune contient |
603
624
  | `GET` | `/admin/mailboxes/created-before` | Boîtes ouvertes avant `created_before`, quelle que soit leur activité |
604
625
  | `GET` | `/admin/mailboxes/empty` | Boîtes par lesquelles rien n'est jamais passé, sans critère de date |
@@ -619,8 +640,12 @@ la console (cookie), qui elle n'ouvre rien d'autre.
619
640
  d'emprunter l'identité d'un autre agent. À préférer au jeton partagé dès que les agents
620
641
  ne se font pas mutuellement confiance.
621
642
  - Comparaison des jetons à **temps constant**, en-têtes de sécurité (`helmet`), **rate
622
- limiting** par IP, corps JSON limité à 2 Mo, erreurs internes génériques (le détail reste
623
- dans les logs).
643
+ limiting** par IP, corps JSON limité à 2 Mo — sauf l'envoi d'un message, dont la limite
644
+ couvre ses pièces jointes et ne s'applique qu'une fois l'agent authentifié —, erreurs
645
+ internes génériques (le détail reste dans les logs).
646
+ - **Pièces jointes** : servies uniquement aux deux boîtes du message (et à la console),
647
+ toujours en `Content-Disposition: attachment`, jamais affichées en ligne ; leur nom est
648
+ réduit à sa dernière composante, il ne peut désigner aucun autre répertoire.
624
649
  - **Isolation des boîtes** garantie côté serveur : les identifiants de messages fournis par
625
650
  un agent sont systématiquement filtrés sur ses propres livraisons.
626
651
  - La console web n'insère jamais de HTML fourni par un agent : le corps des messages est
@@ -659,7 +684,8 @@ src/
659
684
  index.ts # point d'entrée : --server, --watch, --help, --version, sinon mode MCP
660
685
  config.ts # lecture et validation des variables d'environnement
661
686
  logger.ts # journalisation sur stderr (stdout est réservé au JSON-RPC)
662
- core/store.ts # SQLite : agents, messages, livraisons, dossiers
687
+ core/store.ts # SQLite : agents, messages, livraisons, dossiers, pièces jointes
688
+ core/attachments.ts # limites des pièces jointes, en-tête de téléchargement
663
689
  server/app.ts # API HTTP, authentification, supervision
664
690
  server/watchers.ts # présence des veilleurs, en mémoire : état de connexion
665
691
  server/start.ts # démarrage, arrêt propre, purge de la corbeille
@@ -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.
@@ -5,6 +5,17 @@ export declare class MessengerClientError extends Error {
5
5
  readonly status?: number | undefined;
6
6
  constructor(message: string, status?: number | undefined);
7
7
  }
8
+ /** Pièce jointe à l'envoi, contenu encodé en base64. */
9
+ export interface OutgoingAttachment {
10
+ filename: string;
11
+ mime_type: string | null;
12
+ content_base64: string;
13
+ }
14
+ export interface DownloadedAttachment {
15
+ filename: string;
16
+ mime_type: string;
17
+ data: Buffer;
18
+ }
8
19
  export interface WaitResult {
9
20
  timed_out: boolean;
10
21
  message: Message | null;
@@ -68,9 +79,11 @@ export declare class MessengerClient {
68
79
  reply_to?: string | null;
69
80
  thread_title?: string | null;
70
81
  thread_description?: string | null;
82
+ attachments?: OutgoingAttachment[];
71
83
  }): Promise<SendMessageResult>;
72
84
  listMessages(options?: ListMessagesOptions): Promise<MessageSummary[]>;
73
85
  getMessage(messageId: string, markRead?: boolean): Promise<Message>;
86
+ downloadAttachment(attachmentId: string): Promise<DownloadedAttachment>;
74
87
  markMessages(messageIds: string[], state: {
75
88
  read?: boolean;
76
89
  starred?: boolean;
@@ -104,4 +117,7 @@ export declare class MessengerClient {
104
117
  watcher?: boolean;
105
118
  }): Promise<WaitResult>;
106
119
  private request;
120
+ private send;
121
+ /** Erreur lisible tirée d'une réponse en échec. */
122
+ private failure;
107
123
  }
@@ -1,3 +1,4 @@
1
+ import { filenameFromDisposition } from "../core/attachments.js";
1
2
  /** Échec d'un appel au serveur boîte aux lettres (réseau, authentification ou métier). */
2
3
  export class MessengerClientError extends Error {
3
4
  status;
@@ -105,6 +106,16 @@ export class MessengerClient {
105
106
  query: { mark_read: markRead },
106
107
  });
107
108
  }
109
+ async downloadAttachment(attachmentId) {
110
+ const response = await this.send("GET", `/api/attachments/${encodeURIComponent(attachmentId)}`);
111
+ if (!response.ok)
112
+ throw await this.failure(response);
113
+ return {
114
+ filename: filenameFromDisposition(response.headers.get("content-disposition")) ?? attachmentId,
115
+ mime_type: response.headers.get("content-type") ?? "application/octet-stream",
116
+ data: Buffer.from(await response.arrayBuffer()),
117
+ };
118
+ }
108
119
  async markMessages(messageIds, state) {
109
120
  const { updated } = await this.request("POST", "/api/messages/mark", {
110
121
  body: { message_ids: messageIds, ...state },
@@ -160,6 +171,13 @@ export class MessengerClient {
160
171
  });
161
172
  }
162
173
  async request(method, routePath, options = {}) {
174
+ const response = await this.send(method, routePath, options);
175
+ if (!response.ok)
176
+ throw await this.failure(response);
177
+ const text = await response.text();
178
+ return (text ? safeParse(text) : null);
179
+ }
180
+ async send(method, routePath, options = {}) {
163
181
  const url = new URL(this.config.serverUrl + routePath);
164
182
  for (const [key, value] of Object.entries(options.query ?? {})) {
165
183
  if (value === undefined || value === null || value === "")
@@ -171,9 +189,8 @@ export class MessengerClient {
171
189
  headers.authorization = `Bearer ${this.config.token}`;
172
190
  if (options.body !== undefined)
173
191
  headers["content-type"] = "application/json";
174
- let response;
175
192
  try {
176
- response = await fetch(url, {
193
+ return await fetch(url, {
177
194
  method,
178
195
  headers,
179
196
  body: options.body === undefined ? undefined : JSON.stringify(options.body),
@@ -184,15 +201,15 @@ export class MessengerClient {
184
201
  const reason = error instanceof Error ? error.message : String(error);
185
202
  throw new MessengerClientError(`cannot reach the messenger server at ${this.config.serverUrl}: ${reason}`);
186
203
  }
204
+ }
205
+ /** Erreur lisible tirée d'une réponse en échec. */
206
+ async failure(response) {
187
207
  const text = await response.text();
188
208
  const payload = text ? safeParse(text) : null;
189
- if (!response.ok) {
190
- const detail = payload && typeof payload === "object" && "error" in payload
191
- ? String(payload.error)
192
- : text.slice(0, 300) || response.statusText;
193
- throw new MessengerClientError(detail, response.status);
194
- }
195
- return payload;
209
+ const detail = payload && typeof payload === "object" && "error" in payload
210
+ ? String(payload.error)
211
+ : text.slice(0, 300) || response.statusText;
212
+ return new MessengerClientError(detail, response.status);
196
213
  }
197
214
  }
198
215
  function safeParse(text) {
@@ -0,0 +1,16 @@
1
+ /** Nombre de pièces jointes par message. */
2
+ export declare const MAX_ATTACHMENTS = 10;
3
+ /** Taille d'une pièce jointe, en octets. */
4
+ export declare const MAX_ATTACHMENT_BYTES: number;
5
+ /** Taille cumulée des pièces jointes d'un message, en octets. */
6
+ export declare const MAX_ATTACHMENTS_TOTAL_BYTES: number;
7
+ export declare function formatBytes(bytes: number): string;
8
+ /**
9
+ * En-tête `Content-Disposition` d'un téléchargement.
10
+ *
11
+ * Toujours `attachment` : servi en ligne, un fichier HTML ou SVG s'exécuterait dans
12
+ * l'origine de la console.
13
+ */
14
+ export declare function contentDisposition(filename: string): string;
15
+ /** Nom de fichier porté par un en-tête produit par `contentDisposition`. */
16
+ export declare function filenameFromDisposition(header: string | null): string | null;
@@ -0,0 +1,28 @@
1
+ /** Nombre de pièces jointes par message. */
2
+ export const MAX_ATTACHMENTS = 10;
3
+ /** Taille d'une pièce jointe, en octets. */
4
+ export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
5
+ /** Taille cumulée des pièces jointes d'un message, en octets. */
6
+ export const MAX_ATTACHMENTS_TOTAL_BYTES = 25 * 1024 * 1024;
7
+ export function formatBytes(bytes) {
8
+ if (bytes < 1024)
9
+ return `${bytes} B`;
10
+ if (bytes < 1024 * 1024)
11
+ return `${(bytes / 1024).toFixed(1)} KB`;
12
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
13
+ }
14
+ /**
15
+ * En-tête `Content-Disposition` d'un téléchargement.
16
+ *
17
+ * Toujours `attachment` : servi en ligne, un fichier HTML ou SVG s'exécuterait dans
18
+ * l'origine de la console.
19
+ */
20
+ export function contentDisposition(filename) {
21
+ const ascii = filename.replace(/[^\x20-\x7e]|["\\]/g, "_");
22
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
23
+ }
24
+ /** Nom de fichier porté par un en-tête produit par `contentDisposition`. */
25
+ export function filenameFromDisposition(header) {
26
+ const encoded = header?.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
27
+ return encoded ? decodeURIComponent(encoded) : null;
28
+ }
@@ -1,10 +1,15 @@
1
1
  import { EventEmitter } from "node:events";
2
- import { type AdminThreadSummary, type Agent, type AgentDirectoryEntry, type DeliveryRole, type FolderInfo, type ListMessagesOptions, type ListThreadsOptions, type MailboxStats, type Message, type MessageSummary, type Priority, type SendMessageInput, type SendMessageResult, type ThreadInfo, type ThreadSummary } from "./types.js";
2
+ import { type AdminThreadSummary, type Agent, type AgentDirectoryEntry, type AttachmentInfo, type DeliveryRole, type FolderInfo, type ListMessagesOptions, type ListThreadsOptions, type MailboxStats, type Message, type MessageSummary, type Priority, type SendMessageInput, type SendMessageResult, type ThreadInfo, type ThreadSummary } from "./types.js";
3
3
  /** Erreur métier : traduite en 4xx par la couche HTTP, en message d'outil côté MCP. */
4
4
  export declare class MessengerError extends Error {
5
5
  readonly status: number;
6
6
  constructor(message: string, status?: number);
7
7
  }
8
+ /** Contenu d'une pièce jointe, avec ses métadonnées. */
9
+ export interface AttachmentContent extends AttachmentInfo {
10
+ message_id: string;
11
+ data: Buffer;
12
+ }
8
13
  export interface EnsureAgentOptions {
9
14
  registered?: boolean;
10
15
  /** Identité de l'agent : d'où il vient. Posée par la configuration. */
@@ -55,6 +60,8 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
55
60
  getMessage(agentId: string, messageId: string, markRead?: boolean): Message;
56
61
  /** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
57
62
  getThread(agentId: string, threadId: string): Message[];
63
+ /** Pièce jointe d'un message livré à cet agent, contenu compris. */
64
+ getAttachment(agentId: string, attachmentId: string): AttachmentContent;
58
65
  getThreadInfo(threadId: string): ThreadInfo | null;
59
66
  /**
60
67
  * Renomme un fil. Le titre est unique et partagé : les participants parlent du même
@@ -183,6 +190,8 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
183
190
  read: boolean;
184
191
  }[];
185
192
  };
193
+ /** Pièce jointe vue du serveur, quelle que soit la boîte. */
194
+ getAttachmentAdmin(attachmentId: string): AttachmentContent;
186
195
  overview(): {
187
196
  messages: number;
188
197
  threads: number;
@@ -196,8 +205,8 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
196
205
  /** Filtre les ids fournis sur ceux réellement livrés à cet agent (isolation des boîtes). */
197
206
  private ownedMessageIds;
198
207
  private assertFolderExists;
199
- /** Destinataire de chaque message demandé, par identifiant de message. */
200
- private recipientsFor;
208
+ /** Destinataire et pièces jointes de chaque message demandé, par identifiant de message. */
209
+ private relatedFor;
201
210
  private toSummary;
202
211
  private toMessage;
203
212
  /** Un message sans aucune livraison n'est plus lisible par personne : on le supprime. */
@@ -2,6 +2,7 @@ import Database from "better-sqlite3";
2
2
  import { EventEmitter } from "node:events";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_TOTAL_BYTES, MAX_ATTACHMENT_BYTES, formatBytes } from "./attachments.js";
5
6
  import { newId } from "./ids.js";
6
7
  import { DELIVERY_ROLES, SYSTEM_FOLDERS, } from "./types.js";
7
8
  /** Erreur métier : traduite en 4xx par la couche HTTP, en message d'outil côté MCP. */
@@ -18,6 +19,9 @@ const MAX_LIMIT = 100;
18
19
  const FOLDER_NAME_RE = /^[a-z0-9][a-z0-9 _-]{0,39}$/;
19
20
  const THREAD_TITLE_MAX = 200;
20
21
  const THREAD_DESCRIPTION_MAX = 2000;
22
+ const FILENAME_MAX = 255;
23
+ const MIME_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
24
+ const DEFAULT_MIME_TYPE = "application/octet-stream";
21
25
  /**
22
26
  * Dernière activité d'une boîte : sa dernière connexion, ou le dernier message qui l'a
23
27
  * touchée. Une boîte qui ne fait que recevoir reste vivante, alors que `last_seen_at`
@@ -115,6 +119,18 @@ export class MessageStore extends EventEmitter {
115
119
  PRIMARY KEY (agent_id, name)
116
120
  );
117
121
 
122
+ CREATE TABLE IF NOT EXISTS attachments (
123
+ id TEXT PRIMARY KEY,
124
+ message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
125
+ position INTEGER NOT NULL,
126
+ filename TEXT NOT NULL,
127
+ mime_type TEXT NOT NULL,
128
+ size INTEGER NOT NULL,
129
+ data BLOB NOT NULL,
130
+ created_at TEXT NOT NULL
131
+ );
132
+
133
+ CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments (message_id, position);
118
134
  CREATE INDEX IF NOT EXISTS idx_deliveries_mailbox ON deliveries (agent_id, folder, created_at DESC);
119
135
  CREATE INDEX IF NOT EXISTS idx_deliveries_message ON deliveries (message_id);
120
136
  CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, created_at);
@@ -223,6 +239,7 @@ export class MessageStore extends EventEmitter {
223
239
  throw new MessengerError("an agent cannot send a message to itself");
224
240
  const title = normalizeLabel(input.thread_title, THREAD_TITLE_MAX, "thread_title");
225
241
  const threadDescription = normalizeLabel(input.thread_description, THREAD_DESCRIPTION_MAX, "thread_description");
242
+ const attachments = normalizeAttachments(input.attachments ?? []);
226
243
  let threadId;
227
244
  let inReplyTo = null;
228
245
  if (input.reply_to) {
@@ -262,6 +279,11 @@ export class MessageStore extends EventEmitter {
262
279
  .prepare(`INSERT INTO messages (id, thread_id, sender_id, subject, body, priority, in_reply_to, created_at)
263
280
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
264
281
  .run(messageId, threadId, from, subject, input.body, priority, inReplyTo, now);
282
+ const insertAttachment = this.db.prepare(`INSERT INTO attachments (id, message_id, position, filename, mime_type, size, data, created_at)
283
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
284
+ attachments.forEach((a, position) => {
285
+ insertAttachment.run(newId("att"), messageId, position, a.filename, a.mime_type, a.data.length, a.data, now);
286
+ });
265
287
  const insert = this.db.prepare(`INSERT INTO deliveries (id, message_id, agent_id, role, folder, read_at, starred, created_at)
266
288
  VALUES (?, ?, ?, ?, ?, ?, 0, ?)`);
267
289
  for (const d of deliveries) {
@@ -316,8 +338,8 @@ export class MessageStore extends EventEmitter {
316
338
  ORDER BY m.created_at DESC, m.id DESC
317
339
  LIMIT ? OFFSET ?`)
318
340
  .all(...params, limit, offset);
319
- const recipients = this.recipientsFor(rows.map((r) => r.message_id));
320
- return rows.map((row) => this.toSummary(row, recipients));
341
+ const related = this.relatedFor(rows.map((r) => r.message_id));
342
+ return rows.map((row) => this.toSummary(row, related));
321
343
  }
322
344
  getMessage(agentId, messageId, markRead = false) {
323
345
  const row = this.db
@@ -339,8 +361,8 @@ export class MessageStore extends EventEmitter {
339
361
  .run(now, agentId, messageId);
340
362
  row.read_at = now;
341
363
  }
342
- const recipients = this.recipientsFor([messageId]);
343
- return this.toMessage(row, recipients);
364
+ const related = this.relatedFor([messageId]);
365
+ return this.toMessage(row, related);
344
366
  }
345
367
  /** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
346
368
  getThread(agentId, threadId) {
@@ -357,8 +379,20 @@ export class MessageStore extends EventEmitter {
357
379
  .all(agentId, threadId);
358
380
  if (rows.length === 0)
359
381
  throw new MessengerError(`thread not found in this mailbox: ${threadId}`, 404);
360
- const recipients = this.recipientsFor(rows.map((r) => r.message_id));
361
- return rows.map((row) => this.toMessage(row, recipients));
382
+ const related = this.relatedFor(rows.map((r) => r.message_id));
383
+ return rows.map((row) => this.toMessage(row, related));
384
+ }
385
+ /** Pièce jointe d'un message livré à cet agent, contenu compris. */
386
+ getAttachment(agentId, attachmentId) {
387
+ const row = this.db
388
+ .prepare(`SELECT a.id AS attachment_id, a.message_id, a.filename, a.mime_type, a.size, a.data
389
+ FROM attachments a
390
+ WHERE a.id = ?
391
+ AND EXISTS (SELECT 1 FROM deliveries d WHERE d.message_id = a.message_id AND d.agent_id = ?)`)
392
+ .get(attachmentId, agentId);
393
+ if (!row)
394
+ throw new MessengerError(`attachment not found in this mailbox: ${attachmentId}`, 404);
395
+ return row;
362
396
  }
363
397
  // --- Fils ---------------------------------------------------------------
364
398
  getThreadInfo(threadId) {
@@ -710,8 +744,8 @@ export class MessageStore extends EventEmitter {
710
744
  ORDER BY m.created_at DESC, m.id DESC
711
745
  LIMIT ? OFFSET ?`)
712
746
  .all(...params, limit, offset);
713
- const recipients = this.recipientsFor(rows.map((r) => r.message_id));
714
- return rows.map((row) => this.toSummary(row, recipients));
747
+ const related = this.relatedFor(rows.map((r) => r.message_id));
748
+ return rows.map((row) => this.toSummary(row, related));
715
749
  }
716
750
  /**
717
751
  * Fils du serveur entier, le plus récemment actif en tête.
@@ -806,8 +840,8 @@ export class MessageStore extends EventEmitter {
806
840
  WHERE m.thread_id = ?
807
841
  ORDER BY m.created_at ASC, m.id ASC`)
808
842
  .all(threadId);
809
- const recipients = this.recipientsFor(rows.map((r) => r.message_id));
810
- return rows.map((row) => ({ ...this.toSummary(row, recipients), body: row.body }));
843
+ const related = this.relatedFor(rows.map((r) => r.message_id));
844
+ return rows.map((row) => ({ ...this.toSummary(row, related), body: row.body }));
811
845
  }
812
846
  /** Message complet vu du serveur, avec l'état de lecture de chaque destinataire. */
813
847
  getMessageAdmin(messageId) {
@@ -818,12 +852,12 @@ export class MessageStore extends EventEmitter {
818
852
  .get(messageId);
819
853
  if (!row)
820
854
  throw new MessengerError(`message not found: ${messageId}`, 404);
821
- const recipients = this.recipientsFor([messageId]);
855
+ const related = this.relatedFor([messageId]);
822
856
  const deliveries = this.db
823
857
  .prepare("SELECT agent_id, role, folder, read_at FROM deliveries WHERE message_id = ? ORDER BY role, agent_id")
824
858
  .all(messageId);
825
859
  return {
826
- ...this.toMessage(row, recipients),
860
+ ...this.toMessage(row, related),
827
861
  deliveries: deliveries.map((d) => ({
828
862
  agent_id: d.agent_id,
829
863
  role: d.role,
@@ -832,6 +866,16 @@ export class MessageStore extends EventEmitter {
832
866
  })),
833
867
  };
834
868
  }
869
+ /** Pièce jointe vue du serveur, quelle que soit la boîte. */
870
+ getAttachmentAdmin(attachmentId) {
871
+ const row = this.db
872
+ .prepare(`SELECT id AS attachment_id, message_id, filename, mime_type, size, data
873
+ FROM attachments WHERE id = ?`)
874
+ .get(attachmentId);
875
+ if (!row)
876
+ throw new MessengerError(`attachment not found: ${attachmentId}`, 404);
877
+ return row;
878
+ }
835
879
  overview() {
836
880
  const messages = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get().n;
837
881
  const threads = this.db.prepare("SELECT COUNT(DISTINCT thread_id) AS n FROM messages").get().n;
@@ -878,28 +922,36 @@ export class MessageStore extends EventEmitter {
878
922
  if (!row)
879
923
  throw new MessengerError(`unknown folder: ${folder}`, 404);
880
924
  }
881
- /** Destinataire de chaque message demandé, par identifiant de message. */
882
- recipientsFor(messageIds) {
883
- const map = new Map();
925
+ /** Destinataire et pièces jointes de chaque message demandé, par identifiant de message. */
926
+ relatedFor(messageIds) {
927
+ const related = { recipients: new Map(), attachments: new Map() };
884
928
  const ids = dedupe(messageIds);
885
929
  if (ids.length === 0)
886
- return map;
930
+ return related;
887
931
  const placeholders = ids.map(() => "?").join(",");
888
- const rows = this.db
932
+ const recipients = this.db
889
933
  .prepare(`SELECT message_id, agent_id FROM deliveries
890
934
  WHERE message_id IN (${placeholders}) AND role = 'to'`)
891
935
  .all(...ids);
892
- for (const row of rows)
893
- map.set(row.message_id, row.agent_id);
894
- return map;
936
+ for (const row of recipients)
937
+ related.recipients.set(row.message_id, row.agent_id);
938
+ const attachments = this.db
939
+ .prepare(`SELECT id AS attachment_id, message_id, filename, mime_type, size FROM attachments
940
+ WHERE message_id IN (${placeholders})
941
+ ORDER BY message_id, position`)
942
+ .all(...ids);
943
+ for (const { message_id, ...info } of attachments) {
944
+ related.attachments.set(message_id, [...(related.attachments.get(message_id) ?? []), info]);
945
+ }
946
+ return related;
895
947
  }
896
- toSummary(row, recipients) {
948
+ toSummary(row, related) {
897
949
  return {
898
950
  message_id: row.message_id,
899
951
  thread_id: row.thread_id,
900
952
  thread_title: row.thread_title,
901
953
  from: row.sender_id,
902
- to: recipients.get(row.message_id) ?? "",
954
+ to: related.recipients.get(row.message_id) ?? "",
903
955
  subject: row.subject,
904
956
  preview: preview(row.body),
905
957
  priority: row.priority,
@@ -909,10 +961,11 @@ export class MessageStore extends EventEmitter {
909
961
  starred: Boolean(row.starred),
910
962
  created_at: row.created_at,
911
963
  in_reply_to: row.in_reply_to,
964
+ attachments: related.attachments.get(row.message_id) ?? [],
912
965
  };
913
966
  }
914
- toMessage(row, recipients) {
915
- const { preview: _preview, ...summary } = this.toSummary(row, recipients);
967
+ toMessage(row, related) {
968
+ const { preview: _preview, ...summary } = this.toSummary(row, related);
916
969
  return { ...summary, body: row.body };
917
970
  }
918
971
  /** Un message sans aucune livraison n'est plus lisible par personne : on le supprime. */
@@ -941,6 +994,47 @@ function normalizeLabel(value, max, field) {
941
994
  throw new MessengerError(`${field} must be at most ${max} characters`);
942
995
  return trimmed;
943
996
  }
997
+ /**
998
+ * Valide les pièces jointes d'un envoi : nombre, tailles, nom et type.
999
+ *
1000
+ * Le nom ne garde que sa dernière composante : il sert à nommer un fichier chez le
1001
+ * destinataire, et ne doit jamais désigner un autre répertoire que celui qu'il choisit.
1002
+ */
1003
+ function normalizeAttachments(attachments) {
1004
+ if (attachments.length > MAX_ATTACHMENTS) {
1005
+ throw new MessengerError(`at most ${MAX_ATTACHMENTS} attachments per message`);
1006
+ }
1007
+ let total = 0;
1008
+ const normalized = attachments.map((attachment) => {
1009
+ const filename = sanitizeFilename(attachment.filename);
1010
+ if (attachment.data.length === 0)
1011
+ throw new MessengerError(`attachment is empty: ${filename}`);
1012
+ if (attachment.data.length > MAX_ATTACHMENT_BYTES) {
1013
+ throw new MessengerError(`attachment exceeds ${formatBytes(MAX_ATTACHMENT_BYTES)}: ${filename}`, 413);
1014
+ }
1015
+ total += attachment.data.length;
1016
+ const mimeType = (attachment.mime_type ?? "").trim().toLowerCase() || DEFAULT_MIME_TYPE;
1017
+ if (!MIME_TYPE_RE.test(mimeType))
1018
+ throw new MessengerError(`invalid mime_type for ${filename}: ${mimeType}`);
1019
+ return { filename, mime_type: mimeType, data: attachment.data };
1020
+ });
1021
+ if (total > MAX_ATTACHMENTS_TOTAL_BYTES) {
1022
+ throw new MessengerError(`attachments exceed ${formatBytes(MAX_ATTACHMENTS_TOTAL_BYTES)} in total`, 413);
1023
+ }
1024
+ return normalized;
1025
+ }
1026
+ function sanitizeFilename(value) {
1027
+ const base = String(value ?? "")
1028
+ .split(/[\\/]/)
1029
+ .pop()
1030
+ .replace(/[\u0000-\u001f\u007f<>:"|?*]/g, "_")
1031
+ .trim();
1032
+ if (!base || base === "." || base === "..")
1033
+ throw new MessengerError("attachment filename cannot be empty");
1034
+ if (base.length > FILENAME_MAX)
1035
+ throw new MessengerError(`attachment filename must be at most ${FILENAME_MAX} characters`);
1036
+ return base;
1037
+ }
944
1038
  function dedupe(values) {
945
1039
  return [...new Set(values)];
946
1040
  }
@@ -35,6 +35,21 @@ 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
+ /** Pièce jointe telle qu'elle voyage avec un message : ses métadonnées, sans son contenu. */
39
+ export interface AttachmentInfo {
40
+ attachment_id: string;
41
+ filename: string;
42
+ mime_type: string;
43
+ /** Taille en octets. */
44
+ size: number;
45
+ }
46
+ /** Pièce jointe fournie à l'envoi. */
47
+ export interface AttachmentUpload {
48
+ filename: string;
49
+ /** Type MIME ; `application/octet-stream` s'il est absent. */
50
+ mime_type?: string | null;
51
+ data: Buffer;
52
+ }
38
53
  export interface SendMessageInput {
39
54
  from: string;
40
55
  /** Un échange lie exactement deux boîtes : un seul destinataire par message. */
@@ -47,6 +62,7 @@ export interface SendMessageInput {
47
62
  /** Nom lisible donné au fil. Sur une réponse, renomme le fil existant. */
48
63
  thread_title?: string | null;
49
64
  thread_description?: string | null;
65
+ attachments?: AttachmentUpload[];
50
66
  }
51
67
  /**
52
68
  * Intitulé d'un fil de discussion.
@@ -98,6 +114,7 @@ export interface MessageSummary {
98
114
  starred: boolean;
99
115
  created_at: string;
100
116
  in_reply_to: string | null;
117
+ attachments: AttachmentInfo[];
101
118
  }
102
119
  export interface Message extends Omit<MessageSummary, "preview"> {
103
120
  body: string;
@@ -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 });
@@ -1,10 +1,45 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import { z } from "zod";
2
5
  import { writeHandoff } from "../cli/watch-handoff.js";
6
+ import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_TOTAL_BYTES, MAX_ATTACHMENT_BYTES, formatBytes } from "../core/attachments.js";
3
7
  import { PRIORITIES, SYSTEM_FOLDERS } from "../core/types.js";
4
8
  import { rebind } from "./mailbox-registry.js";
5
9
  import { forgetCandidate, getResumeState } from "./resume-state.js";
6
10
  /** Nom publié : l'agent recopie la commande, il n'a pas à connaître de chemin local. */
7
11
  const PACKAGE_NAME = "@imenam/mcp-messenger";
12
+ /** Durée maximale d'un `Monitor` côté hôte : au-delà, l'hôte la ramène à cette valeur. */
13
+ const MONITOR_TIMEOUT_MS = 1_800_000;
14
+ /** Répertoire où `save_attachment` écrit quand l'agent ne donne pas de destination. */
15
+ const DEFAULT_ATTACHMENT_DIR = path.join(os.tmpdir(), "mcp-messenger", "attachments");
16
+ /** Type MIME déduit de l'extension, pour les formats courants. */
17
+ const MIME_TYPES = {
18
+ ".txt": "text/plain",
19
+ ".md": "text/markdown",
20
+ ".csv": "text/csv",
21
+ ".json": "application/json",
22
+ ".xml": "application/xml",
23
+ ".yaml": "application/yaml",
24
+ ".yml": "application/yaml",
25
+ ".html": "text/html",
26
+ ".css": "text/css",
27
+ ".js": "text/javascript",
28
+ ".log": "text/plain",
29
+ ".pdf": "application/pdf",
30
+ ".png": "image/png",
31
+ ".jpg": "image/jpeg",
32
+ ".jpeg": "image/jpeg",
33
+ ".gif": "image/gif",
34
+ ".webp": "image/webp",
35
+ ".svg": "image/svg+xml",
36
+ ".zip": "application/zip",
37
+ ".gz": "application/gzip",
38
+ ".tar": "application/x-tar",
39
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
40
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
41
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
42
+ };
8
43
  const FOLDER_HINT = `Folder name. System folders: ${SYSTEM_FOLDERS.join(", ")}. Custom folders come from list_folders.`;
9
44
  export const TOOLS = [
10
45
  {
@@ -105,6 +140,23 @@ export const TOOLS = [
105
140
  description: "Plain-language name of the conversation, e.g. 'Résolution de l'échec du test de parsing'. Name the thread when you open it — you already know what it is about. On a reply it renames the thread for everyone (same effect as rename_thread).",
106
141
  },
107
142
  thread_description: { type: "string", description: "Optional longer description of the thread." },
143
+ attachments: {
144
+ type: "array",
145
+ maxItems: MAX_ATTACHMENTS,
146
+ description: `Files to attach, read from this machine's disk. At most ${MAX_ATTACHMENTS} files, ${formatBytes(MAX_ATTACHMENT_BYTES)} each and ${formatBytes(MAX_ATTACHMENTS_TOTAL_BYTES)} in total. The recipient saves them with save_attachment.`,
147
+ items: {
148
+ type: "object",
149
+ properties: {
150
+ path: {
151
+ type: "string",
152
+ description: "Path of the file to attach. Prefer an absolute path; a relative one is resolved against this MCP server's working directory.",
153
+ },
154
+ name: { type: "string", description: "File name shown to the recipient. Defaults to the file's own name." },
155
+ },
156
+ required: ["path"],
157
+ additionalProperties: false,
158
+ },
159
+ },
108
160
  },
109
161
  required: ["to", "subject", "body"],
110
162
  additionalProperties: false,
@@ -150,6 +202,23 @@ export const TOOLS = [
150
202
  additionalProperties: false,
151
203
  },
152
204
  },
205
+ {
206
+ name: "save_attachment",
207
+ description: "Save a file attached to a message of this mailbox onto this machine's disk, and return the path it was written to. Attachment ids are listed under the message by read_message and get_thread. Read the saved file with your usual file tools.",
208
+ inputSchema: {
209
+ type: "object",
210
+ properties: {
211
+ attachment_id: { type: "string", description: "Attachment id (att_…) shown under the message." },
212
+ path: {
213
+ type: "string",
214
+ description: "Where to save it: an existing directory (the file keeps its name) or a file path. Defaults to a directory under the system temp folder.",
215
+ },
216
+ overwrite: { type: "boolean", description: "Replace an existing file at that path. Defaults to false." },
217
+ },
218
+ required: ["attachment_id"],
219
+ additionalProperties: false,
220
+ },
221
+ },
153
222
  {
154
223
  name: "mark_messages",
155
224
  description: "Mark messages as read or unread, and/or star or unstar them.",
@@ -217,7 +286,7 @@ export const TOOLS = [
217
286
  },
218
287
  {
219
288
  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.",
289
+ 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
290
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
222
291
  },
223
292
  {
@@ -242,6 +311,15 @@ const schemas = {
242
311
  reply_to: z.string().optional(),
243
312
  thread_name: z.string().trim().min(1).max(200).optional(),
244
313
  thread_description: z.string().trim().min(1).max(2000).optional(),
314
+ attachments: z
315
+ .array(z.object({ path: z.string().trim().min(1), name: z.string().trim().min(1).max(255).optional() }))
316
+ .max(MAX_ATTACHMENTS)
317
+ .optional(),
318
+ }),
319
+ save_attachment: z.object({
320
+ attachment_id: z.string().trim().min(1),
321
+ path: z.string().trim().min(1).optional(),
322
+ overwrite: z.boolean().optional(),
245
323
  }),
246
324
  list_messages: z.object({
247
325
  folder: z.string().optional(),
@@ -423,6 +501,7 @@ export async function callTool(client, name, rawArgs) {
423
501
  }
424
502
  case "send_message": {
425
503
  const input = schemas.send_message.parse(args);
504
+ const attachments = await readAttachments(input.attachments ?? []);
426
505
  const result = await client.sendMessage({
427
506
  to: input.to,
428
507
  subject: input.subject,
@@ -432,12 +511,16 @@ export async function callTool(client, name, rawArgs) {
432
511
  // Le protocole HTTP et le stockage gardent `thread_title` ; seul l'outil dit `thread_name`.
433
512
  thread_title: input.thread_name ?? null,
434
513
  thread_description: input.thread_description ?? null,
514
+ attachments,
435
515
  });
436
516
  const lines = [
437
517
  `Message sent: ${result.message_id}`,
438
518
  `Thread: ${formatThreadRef(result.thread_title, result.thread_id)}`,
439
519
  `Delivered to: ${result.delivered_to}`,
440
520
  ];
521
+ if (attachments.length > 0) {
522
+ lines.push(`Attachments: ${attachments.map((a) => a.filename).join(", ")}`);
523
+ }
441
524
  if (!result.thread_title && !input.reply_to) {
442
525
  lines.push("This thread has no name yet — rename_thread with its thread_id makes it readable for everyone.");
443
526
  }
@@ -470,6 +553,22 @@ export async function callTool(client, name, rawArgs) {
470
553
  .join("\n");
471
554
  return [header, "", ...messages.map(formatMessage)].join("\n\n---\n\n");
472
555
  }
556
+ case "save_attachment": {
557
+ const input = schemas.save_attachment.parse(args);
558
+ const attachment = await client.downloadAttachment(input.attachment_id);
559
+ const target = await attachmentTarget(input.path, input.attachment_id, attachment.filename);
560
+ await mkdir(path.dirname(target), { recursive: true });
561
+ try {
562
+ await writeFile(target, attachment.data, { flag: input.overwrite ? "w" : "wx" });
563
+ }
564
+ catch (error) {
565
+ if (error.code === "EEXIST") {
566
+ throw new Error(`a file already exists at ${target} — pass overwrite: true or choose another path`);
567
+ }
568
+ throw error;
569
+ }
570
+ return `Saved ${attachment.filename} (${attachment.mime_type}, ${formatBytes(attachment.data.length)}) to ${target}`;
571
+ }
473
572
  case "mark_messages": {
474
573
  const input = schemas.mark_messages.parse(args);
475
574
  if (input.read === undefined && input.starred === undefined) {
@@ -522,6 +621,9 @@ export async function callTool(client, name, rawArgs) {
522
621
  `Notifications are already live on mailbox \`${mailbox}\` (watcher armed since ${stats.watcher.since}).`,
523
622
  "Nothing to do: keep working, incoming messages will interrupt you on their own.",
524
623
  "Do not arm a second watcher — you would get every message twice.",
624
+ "Only exception: if the host just told you your watcher expired or stopped, re-arm it",
625
+ `with the same Monitor call (command \`npx -y ${PACKAGE_NAME} --watch ${mailbox}\`,`,
626
+ `timeout_ms ${MONITOR_TIMEOUT_MS}).`,
525
627
  ].join("\n");
526
628
  }
527
629
  const lines = [
@@ -530,11 +632,15 @@ export async function callTool(client, name, rawArgs) {
530
632
  "Last step — call the Monitor tool now, with exactly these arguments:",
531
633
  ` command: npx -y ${PACKAGE_NAME} --watch ${mailbox}`,
532
634
  " description: Incoming messenger messages",
533
- " persistent: true",
635
+ ` timeout_ms: ${MONITOR_TIMEOUT_MS}`,
534
636
  "",
535
637
  "Until that watcher is armed, nothing will reach you: no message will be announced.",
536
638
  "Once it is, keep working normally — each incoming message shows up on its own, even",
537
639
  "while you are idle. One line per message; read it with read_message.",
640
+ "",
641
+ "The host stops every monitor after 30 minutes at most. Each time it tells you this",
642
+ "watcher expired, call Monitor again right away with the same arguments — do not call",
643
+ "enable_notifications again. Skip that and notifications stop for good.",
538
644
  ];
539
645
  if (client.isAttachedElsewhere) {
540
646
  lines.push("", `Note: you are attached to \`${mailbox}\`, not to this session's own mailbox ` +
@@ -558,6 +664,42 @@ export async function callTool(client, name, rawArgs) {
558
664
  throw new Error(`Unknown tool: ${name}`);
559
665
  }
560
666
  }
667
+ /** Lit sur le disque local les fichiers à joindre, en refusant d'emblée ceux qui dépassent les limites. */
668
+ async function readAttachments(entries) {
669
+ let total = 0;
670
+ const attachments = [];
671
+ for (const entry of entries) {
672
+ const file = path.resolve(entry.path);
673
+ const info = await stat(file).catch(() => null);
674
+ if (!info?.isFile())
675
+ throw new Error(`attachment is not a readable file: ${file}`);
676
+ if (info.size > MAX_ATTACHMENT_BYTES) {
677
+ throw new Error(`attachment exceeds ${formatBytes(MAX_ATTACHMENT_BYTES)}: ${file} (${formatBytes(info.size)})`);
678
+ }
679
+ total += info.size;
680
+ if (total > MAX_ATTACHMENTS_TOTAL_BYTES) {
681
+ throw new Error(`attachments exceed ${formatBytes(MAX_ATTACHMENTS_TOTAL_BYTES)} in total`);
682
+ }
683
+ const filename = entry.name ?? path.basename(file);
684
+ attachments.push({
685
+ filename,
686
+ mime_type: MIME_TYPES[path.extname(filename).toLowerCase()] ?? null,
687
+ content_base64: (await readFile(file)).toString("base64"),
688
+ });
689
+ }
690
+ return attachments;
691
+ }
692
+ /**
693
+ * Chemin d'écriture d'une pièce jointe : dans le répertoire donné sous son propre nom, au
694
+ * chemin donné s'il désigne un fichier, ou dans un répertoire temporaire propre à la pièce.
695
+ */
696
+ async function attachmentTarget(requested, attachmentId, filename) {
697
+ if (!requested)
698
+ return path.join(DEFAULT_ATTACHMENT_DIR, attachmentId, filename);
699
+ const resolved = path.resolve(requested);
700
+ const isDirectory = /[\\/]$/.test(requested) || (await stat(resolved).catch(() => null))?.isDirectory();
701
+ return isDirectory ? path.join(resolved, filename) : resolved;
702
+ }
561
703
  async function renameMailbox(client, name, description) {
562
704
  const agent = await client.updateProfile({ display_name: name, description });
563
705
  return [
@@ -743,7 +885,7 @@ function formatSummary(message) {
743
885
  `${flags} ${message.subject}${priority}`,
744
886
  ` from ${message.from} to ${message.to || "—"} · ${message.created_at}`,
745
887
  ` ${message.preview}`,
746
- ` id ${message.message_id} · thread ${formatThreadRef(message.thread_title, message.thread_id)}`,
888
+ ` id ${message.message_id} · thread ${formatThreadRef(message.thread_title, message.thread_id)}${message.attachments.length > 0 ? ` · ${message.attachments.length} attachment(s)` : ""}`,
747
889
  ].join("\n");
748
890
  }
749
891
  function formatMessage(message) {
@@ -758,5 +900,11 @@ function formatMessage(message) {
758
900
  ]
759
901
  .filter(Boolean)
760
902
  .join("\n");
761
- return `${header}\n\n${message.body}`;
903
+ const attachments = message.attachments.length > 0
904
+ ? `\n\nAttachments (save with save_attachment):\n${message.attachments.map(formatAttachment).join("\n")}`
905
+ : "";
906
+ return `${header}\n\n${message.body}${attachments}`;
907
+ }
908
+ function formatAttachment(attachment) {
909
+ return ` ${attachment.attachment_id} · ${attachment.filename} (${attachment.mime_type}, ${formatBytes(attachment.size)})`;
762
910
  }
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { timingSafeEqual } from "node:crypto";
6
6
  import { rateLimit } from "express-rate-limit";
7
7
  import { z } from "zod";
8
+ import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_TOTAL_BYTES, contentDisposition } from "../core/attachments.js";
8
9
  import { MessengerError, normalizeAgentId } from "../core/store.js";
9
10
  import { PRIORITIES } from "../core/types.js";
10
11
  import { WatcherRegistry } from "./watchers.js";
@@ -12,6 +13,9 @@ import { CONSOLE_COOKIE, ConsoleSessions, consoleSessionId } from "./console-ses
12
13
  import { APP_VERSION } from "../version.js";
13
14
  import { logger } from "../logger.js";
14
15
  const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "ui");
16
+ const JSON_TYPES = ["application/json", "application/*+json"];
17
+ /** Corps d'un envoi : le texte, plus les pièces jointes encodées en base64 (4 octets pour 3). */
18
+ const SEND_BODY_LIMIT = Math.ceil((MAX_ATTACHMENTS_TOTAL_BYTES * 4) / 3) + 1024 * 1024;
15
19
  const agentIdSchema = z.string().trim().min(1).max(128);
16
20
  const idListSchema = z.array(z.string().trim().min(1)).min(1).max(200);
17
21
  /** Nom et description de la boîte : ce que l'agent pose lui-même sur sa session. */
@@ -34,6 +38,14 @@ const sendSchema = z.object({
34
38
  reply_to: z.string().trim().min(1).nullish(),
35
39
  thread_title: z.string().trim().min(1).max(200).nullish(),
36
40
  thread_description: z.string().trim().min(1).max(2000).nullish(),
41
+ attachments: z
42
+ .array(z.object({
43
+ filename: z.string().trim().min(1).max(255),
44
+ mime_type: z.string().trim().max(255).nullish(),
45
+ content_base64: z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/, "expected base64 content"),
46
+ }))
47
+ .max(MAX_ATTACHMENTS)
48
+ .optional(),
37
49
  });
38
50
  const threadLabelSchema = z
39
51
  .object({
@@ -151,7 +163,10 @@ export function createApp({ store, config }) {
151
163
  },
152
164
  crossOriginEmbedderPolicy: false,
153
165
  }));
154
- app.use(express.json({ type: ["application/json", "application/*+json"], limit: "2mb" }));
166
+ // L'envoi d'un message a sa propre limite, plus large pour ses pièces jointes, et ne
167
+ // s'applique qu'une fois l'agent authentifié.
168
+ const jsonBody = express.json({ type: JSON_TYPES, limit: "2mb" });
169
+ app.use((req, res, next) => (isSendRequest(req) ? next() : jsonBody(req, res, next)));
155
170
  app.use(rateLimit({
156
171
  windowMs: config.rateLimit.windowMs,
157
172
  limit: config.rateLimit.max,
@@ -209,6 +224,9 @@ export function createApp({ store, config }) {
209
224
  admin.get("/messages/:id", (req, res) => {
210
225
  res.json(store.getMessageAdmin(String(req.params.id)));
211
226
  });
227
+ admin.get("/attachments/:id", (req, res) => {
228
+ sendAttachment(res, store.getAttachmentAdmin(String(req.params.id)));
229
+ });
212
230
  // Déclarée avant `/threads/:id` : la console lit d'abord les fils, le détail ensuite.
213
231
  admin.get("/threads", (req, res) => {
214
232
  const query = adminThreadsSchema.parse(req.query);
@@ -373,7 +391,7 @@ export function createApp({ store, config }) {
373
391
  api.get("/mailbox", (req, res) => {
374
392
  res.json({ ...store.stats(req.agentId), watcher: watchers.status(req.agentId) });
375
393
  });
376
- api.post("/messages", (req, res) => {
394
+ api.post("/messages", express.json({ type: JSON_TYPES, limit: SEND_BODY_LIMIT }), (req, res) => {
377
395
  const input = sendSchema.parse(req.body ?? {});
378
396
  const result = store.sendMessage({
379
397
  from: req.agentId,
@@ -384,6 +402,11 @@ export function createApp({ store, config }) {
384
402
  reply_to: input.reply_to ?? null,
385
403
  thread_title: input.thread_title ?? null,
386
404
  thread_description: input.thread_description ?? null,
405
+ attachments: (input.attachments ?? []).map((a) => ({
406
+ filename: a.filename,
407
+ mime_type: a.mime_type ?? null,
408
+ data: Buffer.from(a.content_base64, "base64"),
409
+ })),
387
410
  });
388
411
  logger.info(`message ${result.message_id}: ${req.agentId} -> ${result.delivered_to}`);
389
412
  res.status(201).json(result);
@@ -409,6 +432,9 @@ export function createApp({ store, config }) {
409
432
  const markRead = req.query.mark_read !== "false";
410
433
  res.json(store.getMessage(req.agentId, String(req.params.id), markRead));
411
434
  });
435
+ api.get("/attachments/:id", (req, res) => {
436
+ sendAttachment(res, store.getAttachment(req.agentId, String(req.params.id)));
437
+ });
412
438
  api.get("/threads", (req, res) => {
413
439
  const query = listThreadsQuerySchema.parse(req.query);
414
440
  res.json({ threads: store.listThreads(req.agentId, query) });
@@ -485,12 +511,32 @@ export function createApp({ store, config }) {
485
511
  res.status(400).json({ error: "invalid request", details: z.treeifyError(err) });
486
512
  return;
487
513
  }
514
+ // Erreurs du lecteur de corps (trop gros, JSON illisible) : leur message est fait pour être lu.
515
+ if (isExposedHttpError(err)) {
516
+ res.status(err.status).json({ error: err.message });
517
+ return;
518
+ }
488
519
  logger.error("unhandled error", err);
489
520
  // Le détail reste dans les logs : la réponse ne doit rien révéler de l'hôte.
490
521
  res.status(500).json({ error: "internal server error" });
491
522
  });
492
523
  return app;
493
524
  }
525
+ function isExposedHttpError(err) {
526
+ if (typeof err !== "object" || err === null)
527
+ return false;
528
+ const { expose, status } = err;
529
+ return expose === true && typeof status === "number" && status >= 400 && status < 500;
530
+ }
531
+ function isSendRequest(req) {
532
+ return req.method === "POST" && req.path === "/api/messages";
533
+ }
534
+ function sendAttachment(res, attachment) {
535
+ res.setHeader("content-type", attachment.mime_type);
536
+ res.setHeader("content-length", String(attachment.data.length));
537
+ res.setHeader("content-disposition", contentDisposition(attachment.filename));
538
+ res.end(attachment.data);
539
+ }
494
540
  /**
495
541
  * Exige un jeton connu du serveur sur toute requête entrante.
496
542
  *
@@ -27,6 +27,7 @@ const ICONS = {
27
27
  link: ["M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1", "M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"],
28
28
  chevron: ["m9 18 6-6-6-6"],
29
29
  info: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z", "M12 11v5", "M12 8h.01"],
30
+ paperclip: ["m21 11-8.5 8.5a5 5 0 0 1-7-7L14 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L15 7"],
30
31
  trash: ["M3 6h18", "M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2", "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"],
31
32
  };
32
33
 
@@ -961,6 +962,9 @@ function fillMessage(item, message, isLast) {
961
962
  column.appendChild(content);
962
963
 
963
964
  content.appendChild(messageText(message));
965
+ if (message.attachments && message.attachments.length > 0) {
966
+ content.appendChild(attachmentList(message.attachments));
967
+ }
964
968
 
965
969
  const foot = node("div", "message-foot");
966
970
  foot.appendChild(
@@ -986,6 +990,30 @@ function messageText(message) {
986
990
  return container;
987
991
  }
988
992
 
993
+ /** Pièces jointes d'un message : un lien de téléchargement par fichier. */
994
+ function attachmentList(attachments) {
995
+ const list = node("ul", "attachments");
996
+ for (const attachment of attachments) {
997
+ const item = node("li");
998
+ const link = node("a", "attachment");
999
+ link.href = `/admin/attachments/${encodeURIComponent(attachment.attachment_id)}`;
1000
+ link.download = attachment.filename;
1001
+ link.title = attachment.mime_type;
1002
+ link.appendChild(icon("paperclip"));
1003
+ link.appendChild(node("span", "attachment-name", attachment.filename));
1004
+ link.appendChild(node("span", "attachment-size", formatSize(attachment.size)));
1005
+ item.appendChild(link);
1006
+ list.appendChild(item);
1007
+ }
1008
+ return list;
1009
+ }
1010
+
1011
+ function formatSize(bytes) {
1012
+ if (bytes < 1024) return `${bytes} o`;
1013
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
1014
+ return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
1015
+ }
1016
+
989
1017
  // --- Détails d'un message --------------------------------------------------
990
1018
 
991
1019
  /**
@@ -1627,6 +1627,50 @@ body {
1627
1627
  font-weight: 600;
1628
1628
  }
1629
1629
 
1630
+ .attachments {
1631
+ display: flex;
1632
+ flex-wrap: wrap;
1633
+ gap: 8px;
1634
+ margin: 10px 0 0;
1635
+ padding: 0;
1636
+ list-style: none;
1637
+ }
1638
+
1639
+ .attachment {
1640
+ display: inline-flex;
1641
+ align-items: center;
1642
+ gap: 6px;
1643
+ max-width: 100%;
1644
+ padding: 4px 10px;
1645
+ border: 1px solid var(--border);
1646
+ border-radius: var(--radius);
1647
+ background: var(--panel);
1648
+ color: var(--text);
1649
+ font-size: 12px;
1650
+ text-decoration: none;
1651
+ }
1652
+
1653
+ .attachment:hover {
1654
+ background: var(--accent-soft);
1655
+ border-color: var(--accent-softer);
1656
+ }
1657
+
1658
+ .attachment .icon {
1659
+ flex: none;
1660
+ color: var(--muted);
1661
+ }
1662
+
1663
+ .attachment-name {
1664
+ overflow: hidden;
1665
+ text-overflow: ellipsis;
1666
+ white-space: nowrap;
1667
+ }
1668
+
1669
+ .attachment-size {
1670
+ flex: none;
1671
+ color: var(--muted);
1672
+ }
1673
+
1630
1674
  .message-foot {
1631
1675
  display: flex;
1632
1676
  align-items: center;
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.28";
1
+ export declare const APP_VERSION = "1.0.30";
@@ -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.28";
2
+ export const APP_VERSION = "1.0.30";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.28",
3
+ "version": "1.0.30",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",