@imenam/mcp-messenger 1.0.1

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.
@@ -0,0 +1,183 @@
1
+ /** Échec d'un appel au serveur boîte aux lettres (réseau, authentification ou métier). */
2
+ export class MessengerClientError extends Error {
3
+ status;
4
+ constructor(message, status) {
5
+ super(message);
6
+ this.status = status;
7
+ this.name = "MessengerClientError";
8
+ }
9
+ }
10
+ /**
11
+ * Client HTTP du serveur boîte aux lettres.
12
+ *
13
+ * L'identité de l'agent voyage dans `X-Agent-Id` ; le serveur ne l'accepte que si le
14
+ * jeton présenté correspond à sa configuration. C'est l'unique point de sortie réseau
15
+ * du mode MCP : les outils n'appellent jamais `fetch` directement.
16
+ */
17
+ export class MessengerClient {
18
+ config;
19
+ /** Boîte courante. Modifiable en cours de session par `attach_to_mailbox`. */
20
+ currentAgentId;
21
+ /** Boîte allouée à cette session : on peut toujours y revenir après un rattachement. */
22
+ ownAgentId;
23
+ constructor(config) {
24
+ this.config = config;
25
+ this.currentAgentId = config.agentId;
26
+ this.ownAgentId = config.agentId;
27
+ }
28
+ get agentId() {
29
+ if (!this.currentAgentId) {
30
+ throw new MessengerClientError("no mailbox attached yet: the session has no address.");
31
+ }
32
+ return this.currentAgentId;
33
+ }
34
+ /** Adresse fixée par la configuration, s'il y en a une. */
35
+ get pinnedAgentId() {
36
+ return this.config.agentId;
37
+ }
38
+ /** `own: true` désigne la boîte de la session, celle sur laquelle on peut revenir. */
39
+ setAgentId(agentId, options = {}) {
40
+ this.currentAgentId = agentId;
41
+ if (options.own)
42
+ this.ownAgentId = agentId;
43
+ }
44
+ /** Adresse de la boîte propre à cette session. */
45
+ get sessionAgentId() {
46
+ return this.ownAgentId;
47
+ }
48
+ /** true si la session lit une boîte autre que la sienne. */
49
+ get isAttachedElsewhere() {
50
+ return this.ownAgentId !== null && this.currentAgentId !== this.ownAgentId;
51
+ }
52
+ get serverUrl() {
53
+ return this.config.serverUrl;
54
+ }
55
+ async health() {
56
+ return this.request("GET", "/health");
57
+ }
58
+ /** Enregistre la boîte courante. `fresh` exige une adresse encore inconnue du serveur. */
59
+ async register(options = {}) {
60
+ const { agent } = await this.request("POST", "/api/agents/register", {
61
+ body: {
62
+ display_name: this.config.displayName,
63
+ description: this.config.description,
64
+ fresh: options.fresh ?? false,
65
+ },
66
+ });
67
+ return agent;
68
+ }
69
+ /** Renomme la boîte courante, pour la rendre reconnaissable dans l'annuaire. */
70
+ async updateProfile(profile) {
71
+ const { agent } = await this.request("PATCH", "/api/agents/me", { body: profile });
72
+ return agent;
73
+ }
74
+ async getAgent(agentId) {
75
+ const { agent } = await this.request("GET", `/api/agents/${encodeURIComponent(agentId)}`);
76
+ return agent;
77
+ }
78
+ async listAgents() {
79
+ const { agents } = await this.request("GET", "/api/agents");
80
+ return agents;
81
+ }
82
+ async stats() {
83
+ return this.request("GET", "/api/mailbox");
84
+ }
85
+ async sendMessage(input) {
86
+ return this.request("POST", "/api/messages", { body: input });
87
+ }
88
+ async listMessages(options = {}) {
89
+ const { messages } = await this.request("GET", "/api/messages", {
90
+ query: { ...options },
91
+ });
92
+ return messages;
93
+ }
94
+ async getMessage(messageId, markRead = true) {
95
+ return this.request("GET", `/api/messages/${encodeURIComponent(messageId)}`, {
96
+ query: { mark_read: markRead },
97
+ });
98
+ }
99
+ async markMessages(messageIds, state) {
100
+ const { updated } = await this.request("POST", "/api/messages/mark", {
101
+ body: { message_ids: messageIds, ...state },
102
+ });
103
+ return updated;
104
+ }
105
+ async moveMessages(messageIds, folder) {
106
+ const { updated } = await this.request("POST", "/api/messages/move", {
107
+ body: { message_ids: messageIds, folder },
108
+ });
109
+ return updated;
110
+ }
111
+ async deleteMessages(messageIds, permanent = false) {
112
+ const { deleted } = await this.request("POST", "/api/messages/delete", {
113
+ body: { message_ids: messageIds, permanent },
114
+ });
115
+ return deleted;
116
+ }
117
+ async getThread(threadId) {
118
+ const { messages } = await this.request("GET", `/api/threads/${encodeURIComponent(threadId)}`);
119
+ return messages;
120
+ }
121
+ async listFolders() {
122
+ const { folders } = await this.request("GET", "/api/folders");
123
+ return folders;
124
+ }
125
+ async createFolder(name) {
126
+ const { folder } = await this.request("POST", "/api/folders", { body: { name } });
127
+ return folder;
128
+ }
129
+ async deleteFolder(name) {
130
+ const { moved } = await this.request("DELETE", `/api/folders/${encodeURIComponent(name)}`);
131
+ return moved;
132
+ }
133
+ async waitForMessage(timeoutSeconds, folder) {
134
+ // L'attente longue peut dépasser le délai HTTP par défaut : on l'étend d'une marge.
135
+ return this.request("GET", "/api/wait", {
136
+ query: { timeout_seconds: timeoutSeconds, folder },
137
+ timeoutMs: timeoutSeconds * 1000 + this.config.timeoutMs,
138
+ });
139
+ }
140
+ async request(method, routePath, options = {}) {
141
+ const url = new URL(this.config.serverUrl + routePath);
142
+ for (const [key, value] of Object.entries(options.query ?? {})) {
143
+ if (value === undefined || value === null || value === "")
144
+ continue;
145
+ url.searchParams.set(key, String(value));
146
+ }
147
+ const headers = { "x-agent-id": this.agentId, accept: "application/json" };
148
+ if (this.config.token)
149
+ headers.authorization = `Bearer ${this.config.token}`;
150
+ if (options.body !== undefined)
151
+ headers["content-type"] = "application/json";
152
+ let response;
153
+ try {
154
+ response = await fetch(url, {
155
+ method,
156
+ headers,
157
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
158
+ signal: AbortSignal.timeout(options.timeoutMs ?? this.config.timeoutMs),
159
+ });
160
+ }
161
+ catch (error) {
162
+ const reason = error instanceof Error ? error.message : String(error);
163
+ throw new MessengerClientError(`cannot reach the messenger server at ${this.config.serverUrl}: ${reason}`);
164
+ }
165
+ const text = await response.text();
166
+ const payload = text ? safeParse(text) : null;
167
+ if (!response.ok) {
168
+ const detail = payload && typeof payload === "object" && "error" in payload
169
+ ? String(payload.error)
170
+ : text.slice(0, 300) || response.statusText;
171
+ throw new MessengerClientError(detail, response.status);
172
+ }
173
+ return payload;
174
+ }
175
+ }
176
+ function safeParse(text) {
177
+ try {
178
+ return JSON.parse(text);
179
+ }
180
+ catch {
181
+ return { error: text };
182
+ }
183
+ }
@@ -0,0 +1,36 @@
1
+ export declare class ConfigError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ /** Le serveur n'écoute jamais ailleurs : la boîte aux lettres reste locale à la machine. */
5
+ export declare const SERVER_HOST = "127.0.0.1";
6
+ export interface ServerConfig {
7
+ port: number;
8
+ /** Toujours `127.0.0.1` — non configurable. */
9
+ host: string;
10
+ dbPath: string;
11
+ /** Jeton partagé accepté pour tous les agents (ignoré si `agentTokens` est renseigné). */
12
+ token: string | null;
13
+ /** Jeton dédié par agent : `agent_id -> token`. Prioritaire sur `token`. */
14
+ agentTokens: Map<string, string>;
15
+ /** Jeton de l'interface de supervision. À défaut, `token` fait office. Jamais nul. */
16
+ adminToken: string;
17
+ rateLimit: {
18
+ max: number;
19
+ windowMs: number;
20
+ };
21
+ trashRetentionDays: number;
22
+ /** Plafond d'attente d'un `wait_for_message` (secondes). */
23
+ maxWaitSeconds: number;
24
+ }
25
+ export interface AgentConfig {
26
+ /** null = une boîte propre à cette session est allouée au démarrage. */
27
+ agentId: string | null;
28
+ serverUrl: string;
29
+ token: string | null;
30
+ displayName: string | null;
31
+ description: string | null;
32
+ timeoutMs: number;
33
+ }
34
+ export declare function isLoopbackHost(host: string): boolean;
35
+ export declare function loadServerConfig(env?: NodeJS.ProcessEnv): ServerConfig;
36
+ export declare function loadAgentConfig(env?: NodeJS.ProcessEnv): AgentConfig;
@@ -0,0 +1,110 @@
1
+ import path from "node:path";
2
+ /** Adresses sur lesquelles le serveur peut tourner sans authentification. */
3
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1"]);
4
+ export class ConfigError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "ConfigError";
8
+ }
9
+ }
10
+ /** Le serveur n'écoute jamais ailleurs : la boîte aux lettres reste locale à la machine. */
11
+ export const SERVER_HOST = "127.0.0.1";
12
+ export function isLoopbackHost(host) {
13
+ return LOOPBACK_HOSTS.has(host.trim().toLowerCase().replace(/^\[|\]$/g, ""));
14
+ }
15
+ export function loadServerConfig(env = process.env) {
16
+ const port = parseIntEnv(env.MESSENGER_PORT, 3600, "MESSENGER_PORT");
17
+ // L'interface d'écoute n'est pas un réglage : la boîte aux lettres reste sur la machine.
18
+ // Une valeur non-loopback est refusée plutôt qu'ignorée en silence.
19
+ const requestedHost = nonEmpty(env.MESSENGER_HOST);
20
+ if (requestedHost && !isLoopbackHost(requestedHost)) {
21
+ throw new ConfigError(`MESSENGER_HOST=${requestedHost} is refused: the mailbox always listens on ${SERVER_HOST}. ` +
22
+ "To reach it from another machine, put a reverse proxy in front of it.");
23
+ }
24
+ const dbPath = env.MESSENGER_DB_PATH?.trim()
25
+ ? path.resolve(env.MESSENGER_DB_PATH.trim())
26
+ : path.resolve(process.cwd(), "data", "messenger.db");
27
+ const token = nonEmpty(env.MESSENGER_TOKEN);
28
+ const agentTokens = parseAgentTokens(env.MESSENGER_AGENT_TOKENS);
29
+ // Aucun démarrage sans jeton : un serveur qui tourne est un serveur authentifié.
30
+ if (!token && agentTokens.size === 0) {
31
+ throw new ConfigError("a token is required: set MESSENGER_TOKEN (shared) or MESSENGER_AGENT_TOKENS (one token per agent).");
32
+ }
33
+ const adminToken = nonEmpty(env.MESSENGER_ADMIN_TOKEN) ?? token;
34
+ if (!adminToken) {
35
+ throw new ConfigError("MESSENGER_ADMIN_TOKEN is required when only MESSENGER_AGENT_TOKENS is set: the web console needs its own token.");
36
+ }
37
+ return {
38
+ port,
39
+ host: SERVER_HOST,
40
+ dbPath,
41
+ token,
42
+ agentTokens,
43
+ adminToken,
44
+ rateLimit: {
45
+ max: parseIntEnv(env.MESSENGER_RATE_LIMIT_MAX, 600, "MESSENGER_RATE_LIMIT_MAX"),
46
+ windowMs: parseIntEnv(env.MESSENGER_RATE_LIMIT_WINDOW_MINUTES, 15, "MESSENGER_RATE_LIMIT_WINDOW_MINUTES") * 60_000,
47
+ },
48
+ trashRetentionDays: parseIntEnv(env.MESSENGER_TRASH_RETENTION_DAYS, 30, "MESSENGER_TRASH_RETENTION_DAYS"),
49
+ maxWaitSeconds: parseIntEnv(env.MESSENGER_MAX_WAIT_SECONDS, 300, "MESSENGER_MAX_WAIT_SECONDS"),
50
+ };
51
+ }
52
+ export function loadAgentConfig(env = process.env) {
53
+ // Adresse fixe optionnelle. Sans elle, chaque session reçoit sa propre boîte : deux
54
+ // sessions ouvertes sur le même projet ne partagent donc pas leur courrier.
55
+ const agentId = nonEmpty(env.MESSENGER_AGENT_ID);
56
+ if (agentId && /\s/.test(agentId)) {
57
+ throw new ConfigError(`MESSENGER_AGENT_ID must not contain whitespace (got "${agentId}").`);
58
+ }
59
+ const rawUrl = nonEmpty(env.MESSENGER_SERVER_URL);
60
+ if (!rawUrl) {
61
+ throw new ConfigError("MESSENGER_SERVER_URL is required in MCP mode (e.g. http://127.0.0.1:3600).");
62
+ }
63
+ let url;
64
+ try {
65
+ url = new URL(rawUrl);
66
+ }
67
+ catch {
68
+ throw new ConfigError(`MESSENGER_SERVER_URL is not a valid URL: ${rawUrl}`);
69
+ }
70
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
71
+ throw new ConfigError(`MESSENGER_SERVER_URL must use http or https (got ${url.protocol}).`);
72
+ }
73
+ return {
74
+ agentId,
75
+ serverUrl: rawUrl.replace(/\/+$/, ""),
76
+ token: nonEmpty(env.MESSENGER_TOKEN),
77
+ displayName: nonEmpty(env.MESSENGER_AGENT_NAME),
78
+ description: nonEmpty(env.MESSENGER_AGENT_DESCRIPTION),
79
+ timeoutMs: parseIntEnv(env.MESSENGER_TIMEOUT_MS, 15_000, "MESSENGER_TIMEOUT_MS"),
80
+ };
81
+ }
82
+ function parseAgentTokens(raw) {
83
+ const map = new Map();
84
+ if (!raw?.trim())
85
+ return map;
86
+ for (const pair of raw.split(",")) {
87
+ const trimmed = pair.trim();
88
+ if (!trimmed)
89
+ continue;
90
+ const separator = trimmed.indexOf(":");
91
+ if (separator <= 0 || separator === trimmed.length - 1) {
92
+ throw new ConfigError(`MESSENGER_AGENT_TOKENS entry must look like "agent_id:token" (got "${trimmed}").`);
93
+ }
94
+ map.set(trimmed.slice(0, separator).trim(), trimmed.slice(separator + 1).trim());
95
+ }
96
+ return map;
97
+ }
98
+ function nonEmpty(value) {
99
+ const trimmed = value?.trim();
100
+ return trimmed ? trimmed : null;
101
+ }
102
+ function parseIntEnv(value, fallback, name) {
103
+ if (value === undefined || value.trim() === "")
104
+ return fallback;
105
+ const parsed = Number(value);
106
+ if (!Number.isFinite(parsed) || parsed < 0) {
107
+ throw new ConfigError(`${name} must be a non-negative number (got "${value}").`);
108
+ }
109
+ return Math.trunc(parsed);
110
+ }
@@ -0,0 +1,2 @@
1
+ /** Identifiants courts, lisibles dans une conversation d'agent : `msg_a1b2c3d4e5f6a7b8`. */
2
+ export declare function newId(prefix: string): string;
@@ -0,0 +1,5 @@
1
+ import { randomBytes } from "node:crypto";
2
+ /** Identifiants courts, lisibles dans une conversation d'agent : `msg_a1b2c3d4e5f6a7b8`. */
3
+ export function newId(prefix) {
4
+ return `${prefix}_${randomBytes(8).toString("hex")}`;
5
+ }
@@ -0,0 +1,93 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { type Agent, type DeliveryRole, type FolderInfo, type ListMessagesOptions, type MailboxStats, type Message, type MessageSummary, type SendMessageInput, type SendMessageResult } from "./types.js";
3
+ /** Erreur métier : traduite en 4xx par la couche HTTP, en message d'outil côté MCP. */
4
+ export declare class MessengerError extends Error {
5
+ readonly status: number;
6
+ constructor(message: string, status?: number);
7
+ }
8
+ export interface StoreEvents {
9
+ /** Émis pour chaque destinataire d'un message, y compris la copie « sent » de l'expéditeur. */
10
+ message: [{
11
+ agent_id: string;
12
+ message_id: string;
13
+ folder: string;
14
+ role: DeliveryRole;
15
+ }];
16
+ }
17
+ /**
18
+ * Boîte aux lettres persistée en SQLite.
19
+ *
20
+ * Un message est stocké une seule fois (table `messages`) puis « livré » via une ligne
21
+ * par couple (agent, rôle) dans `deliveries`. C'est cette ligne qui porte l'état propre
22
+ * à chaque agent : dossier, lu/non lu, suivi. Un agent ne voit que ses propres livraisons.
23
+ */
24
+ export declare class MessageStore extends EventEmitter<StoreEvents> {
25
+ private readonly db;
26
+ constructor(dbPath: string);
27
+ private migrate;
28
+ 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;
35
+ touchAgent(id: string): void;
36
+ getAgent(id: string): Agent | null;
37
+ listAgents(): Agent[];
38
+ sendMessage(input: SendMessageInput): SendMessageResult;
39
+ listMessages(agentId: string, opts?: ListMessagesOptions): MessageSummary[];
40
+ getMessage(agentId: string, messageId: string, markRead?: boolean): Message;
41
+ /** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
42
+ getThread(agentId: string, threadId: string): Message[];
43
+ markMessages(agentId: string, messageIds: string[], state: {
44
+ read?: boolean;
45
+ starred?: boolean;
46
+ }): number;
47
+ moveMessages(agentId: string, messageIds: string[], folder: string): number;
48
+ /** Sans `permanent`, le message part à la corbeille et reste récupérable. */
49
+ deleteMessages(agentId: string, messageIds: string[], permanent?: boolean): number;
50
+ listFolders(agentId: string): FolderInfo[];
51
+ createFolder(agentId: string, name: string): FolderInfo;
52
+ /** Les messages du dossier retournent dans `inbox` (ou `sent` pour les copies d'envoi). */
53
+ deleteFolder(agentId: string, name: string): number;
54
+ stats(agentId: string): MailboxStats;
55
+ /** Purge les livraisons restées en corbeille au-delà de `days`. Renvoie le nombre supprimé. */
56
+ purgeTrash(days: number): number;
57
+ /** Vue globale : tous les messages du serveur, indépendamment des boîtes. */
58
+ listAllMessages(opts?: {
59
+ search?: string;
60
+ agent?: string;
61
+ thread_id?: string;
62
+ limit?: number;
63
+ offset?: number;
64
+ }): MessageSummary[];
65
+ /** Message complet vu du serveur, avec l'état de lecture de chaque destinataire. */
66
+ getMessageAdmin(messageId: string): Message & {
67
+ deliveries: {
68
+ agent_id: string;
69
+ role: DeliveryRole;
70
+ folder: string;
71
+ read: boolean;
72
+ }[];
73
+ };
74
+ overview(): {
75
+ messages: number;
76
+ threads: number;
77
+ agents: (Agent & {
78
+ sent: number;
79
+ received: number;
80
+ unread: number;
81
+ })[];
82
+ };
83
+ private updateDeliveries;
84
+ /** Filtre les ids fournis sur ceux réellement livrés à cet agent (isolation des boîtes). */
85
+ private ownedMessageIds;
86
+ private assertFolderExists;
87
+ private recipientsFor;
88
+ private toSummary;
89
+ private toMessage;
90
+ /** Un message sans aucune livraison n'est plus lisible par personne : on le supprime. */
91
+ private dropOrphanMessages;
92
+ }
93
+ export declare function normalizeAgentId(id: string): string;