@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.
- package/.env.example +59 -0
- package/LICENSE +15 -0
- package/README.md +317 -0
- package/dist/src/cli/setup-mcp.d.ts +6 -0
- package/dist/src/cli/setup-mcp.js +105 -0
- package/dist/src/client/messenger-client.d.ts +78 -0
- package/dist/src/client/messenger-client.js +183 -0
- package/dist/src/config.d.ts +36 -0
- package/dist/src/config.js +110 -0
- package/dist/src/core/ids.d.ts +2 -0
- package/dist/src/core/ids.js +5 -0
- package/dist/src/core/store.d.ts +93 -0
- package/dist/src/core/store.js +527 -0
- package/dist/src/core/types.d.ts +74 -0
- package/dist/src/core/types.js +5 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +53 -0
- package/dist/src/logger.d.ts +6 -0
- package/dist/src/logger.js +22 -0
- package/dist/src/mcp/start.d.ts +16 -0
- package/dist/src/mcp/start.js +76 -0
- package/dist/src/mcp/tools.d.ts +14 -0
- package/dist/src/mcp/tools.js +403 -0
- package/dist/src/server/app.d.ts +15 -0
- package/dist/src/server/app.js +375 -0
- package/dist/src/server/start.d.ts +14 -0
- package/dist/src/server/start.js +64 -0
- package/dist/src/server/ui/app.js +275 -0
- package/dist/src/server/ui/index.html +53 -0
- package/dist/src/server/ui/styles.css +303 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +2 -0
- package/package.json +59 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import helmet from "helmet";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { rateLimit } from "express-rate-limit";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { MessengerError, normalizeAgentId } from "../core/store.js";
|
|
10
|
+
import { PRIORITIES } from "../core/types.js";
|
|
11
|
+
import { APP_VERSION } from "../version.js";
|
|
12
|
+
import { logger } from "../logger.js";
|
|
13
|
+
const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "ui");
|
|
14
|
+
const agentIdSchema = z.string().trim().min(1).max(128);
|
|
15
|
+
const idListSchema = z.array(z.string().trim().min(1)).min(1).max(200);
|
|
16
|
+
const registerSchema = z.object({
|
|
17
|
+
display_name: z.string().trim().max(200).nullish(),
|
|
18
|
+
description: z.string().trim().max(2000).nullish(),
|
|
19
|
+
/** true = l'adresse doit être inconnue du serveur (allocation d'une boîte neuve). */
|
|
20
|
+
fresh: z.boolean().optional(),
|
|
21
|
+
});
|
|
22
|
+
const sendSchema = z.object({
|
|
23
|
+
to: z.union([agentIdSchema, z.array(agentIdSchema).min(1)]),
|
|
24
|
+
cc: z.union([agentIdSchema, z.array(agentIdSchema)]).optional(),
|
|
25
|
+
subject: z.string().min(1).max(500),
|
|
26
|
+
body: z.string().min(1).max(200_000),
|
|
27
|
+
priority: z.enum(PRIORITIES).optional(),
|
|
28
|
+
reply_to: z.string().trim().min(1).nullish(),
|
|
29
|
+
});
|
|
30
|
+
const listQuerySchema = z.object({
|
|
31
|
+
folder: z.string().trim().min(1).optional(),
|
|
32
|
+
unread_only: z.coerce.boolean().optional(),
|
|
33
|
+
thread_id: z.string().trim().min(1).optional(),
|
|
34
|
+
from: z.string().trim().min(1).optional(),
|
|
35
|
+
search: z.string().trim().min(1).optional(),
|
|
36
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
37
|
+
offset: z.coerce.number().int().min(0).optional(),
|
|
38
|
+
});
|
|
39
|
+
const markSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
message_ids: idListSchema,
|
|
42
|
+
read: z.boolean().optional(),
|
|
43
|
+
starred: z.boolean().optional(),
|
|
44
|
+
})
|
|
45
|
+
.refine((v) => v.read !== undefined || v.starred !== undefined, {
|
|
46
|
+
message: "provide `read` and/or `starred`",
|
|
47
|
+
});
|
|
48
|
+
const moveSchema = z.object({ message_ids: idListSchema, folder: z.string().trim().min(1) });
|
|
49
|
+
const deleteSchema = z.object({ message_ids: idListSchema, permanent: z.boolean().optional() });
|
|
50
|
+
const folderSchema = z.object({ name: z.string().trim().min(1).max(40) });
|
|
51
|
+
const waitQuerySchema = z.object({
|
|
52
|
+
timeout_seconds: z.coerce.number().int().min(1).optional(),
|
|
53
|
+
folder: z.string().trim().min(1).optional(),
|
|
54
|
+
});
|
|
55
|
+
const adminListSchema = z.object({
|
|
56
|
+
search: z.string().trim().min(1).optional(),
|
|
57
|
+
agent: z.string().trim().min(1).optional(),
|
|
58
|
+
thread_id: z.string().trim().min(1).optional(),
|
|
59
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
60
|
+
offset: z.coerce.number().int().min(0).optional(),
|
|
61
|
+
});
|
|
62
|
+
export function createApp({ store, config }) {
|
|
63
|
+
const app = express();
|
|
64
|
+
app.disable("x-powered-by");
|
|
65
|
+
app.set("trust proxy", false);
|
|
66
|
+
app.use(helmet({
|
|
67
|
+
contentSecurityPolicy: {
|
|
68
|
+
directives: {
|
|
69
|
+
defaultSrc: ["'self'"],
|
|
70
|
+
scriptSrc: ["'self'"],
|
|
71
|
+
styleSrc: ["'self'"],
|
|
72
|
+
imgSrc: ["'self'", "data:"],
|
|
73
|
+
connectSrc: ["'self'"],
|
|
74
|
+
objectSrc: ["'none'"],
|
|
75
|
+
frameAncestors: ["'none'"],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
crossOriginEmbedderPolicy: false,
|
|
79
|
+
}));
|
|
80
|
+
app.use(express.json({ type: ["application/json", "application/*+json"], limit: "2mb" }));
|
|
81
|
+
app.use(rateLimit({
|
|
82
|
+
windowMs: config.rateLimit.windowMs,
|
|
83
|
+
limit: config.rateLimit.max,
|
|
84
|
+
standardHeaders: "draft-8",
|
|
85
|
+
legacyHeaders: false,
|
|
86
|
+
}));
|
|
87
|
+
// Porte d'entrée unique : aucune requête, quelle qu'elle soit, n'est servie sans
|
|
88
|
+
// jeton valide. Les contrôles par route (identité de l'agent, console) viennent ensuite.
|
|
89
|
+
app.use(requireAnyToken(config));
|
|
90
|
+
// --- État du service ------------------------------------------------------
|
|
91
|
+
app.get("/health", (_req, res) => {
|
|
92
|
+
const overview = store.overview();
|
|
93
|
+
res.json({
|
|
94
|
+
status: "ok",
|
|
95
|
+
service: "mcp-messenger",
|
|
96
|
+
version: APP_VERSION,
|
|
97
|
+
agents: overview.agents.length,
|
|
98
|
+
messages: overview.messages,
|
|
99
|
+
// Toujours vrai : le serveur ne démarre pas sans jeton, et cette route en exige un.
|
|
100
|
+
auth_required: true,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
// --- API agents -----------------------------------------------------------
|
|
104
|
+
const api = express.Router();
|
|
105
|
+
api.use(authenticateAgent(config));
|
|
106
|
+
api.post("/agents/register", (req, res) => {
|
|
107
|
+
const input = registerSchema.parse(req.body ?? {});
|
|
108
|
+
// Une session qui alloue sa propre boîte ne doit jamais tomber sur celle d'une autre.
|
|
109
|
+
if (input.fresh && store.getAgent(req.agentId)) {
|
|
110
|
+
throw new MessengerError(`mailbox already exists: ${req.agentId}`, 409);
|
|
111
|
+
}
|
|
112
|
+
const agent = store.ensureAgent(req.agentId, {
|
|
113
|
+
registered: true,
|
|
114
|
+
display_name: input.display_name ?? null,
|
|
115
|
+
description: input.description ?? null,
|
|
116
|
+
});
|
|
117
|
+
res.json({ agent });
|
|
118
|
+
});
|
|
119
|
+
api.get("/agents", (_req, res) => {
|
|
120
|
+
res.json({ agents: store.listAgents() });
|
|
121
|
+
});
|
|
122
|
+
api.patch("/agents/me", (req, res) => {
|
|
123
|
+
const input = registerSchema.parse(req.body ?? {});
|
|
124
|
+
res.json({
|
|
125
|
+
agent: store.ensureAgent(req.agentId, {
|
|
126
|
+
registered: true,
|
|
127
|
+
display_name: input.display_name ?? null,
|
|
128
|
+
description: input.description ?? null,
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
api.get("/agents/:id", (req, res) => {
|
|
133
|
+
const agent = store.getAgent(String(req.params.id));
|
|
134
|
+
if (!agent)
|
|
135
|
+
throw new MessengerError(`mailbox not found: ${req.params.id}`, 404);
|
|
136
|
+
res.json({ agent });
|
|
137
|
+
});
|
|
138
|
+
api.get("/mailbox", (req, res) => {
|
|
139
|
+
res.json(store.stats(req.agentId));
|
|
140
|
+
});
|
|
141
|
+
api.post("/messages", (req, res) => {
|
|
142
|
+
const input = sendSchema.parse(req.body ?? {});
|
|
143
|
+
const result = store.sendMessage({
|
|
144
|
+
from: req.agentId,
|
|
145
|
+
to: toArray(input.to),
|
|
146
|
+
cc: toArray(input.cc),
|
|
147
|
+
subject: input.subject,
|
|
148
|
+
body: input.body,
|
|
149
|
+
priority: input.priority,
|
|
150
|
+
reply_to: input.reply_to ?? null,
|
|
151
|
+
});
|
|
152
|
+
logger.info(`message ${result.message_id}: ${req.agentId} -> ${result.delivered_to.join(", ")}`);
|
|
153
|
+
res.status(201).json(result);
|
|
154
|
+
});
|
|
155
|
+
api.get("/messages", (req, res) => {
|
|
156
|
+
const query = listQuerySchema.parse(req.query);
|
|
157
|
+
res.json({ messages: store.listMessages(req.agentId, query) });
|
|
158
|
+
});
|
|
159
|
+
api.post("/messages/mark", (req, res) => {
|
|
160
|
+
const input = markSchema.parse(req.body ?? {});
|
|
161
|
+
const updated = store.markMessages(req.agentId, input.message_ids, { read: input.read, starred: input.starred });
|
|
162
|
+
res.json({ updated });
|
|
163
|
+
});
|
|
164
|
+
api.post("/messages/move", (req, res) => {
|
|
165
|
+
const input = moveSchema.parse(req.body ?? {});
|
|
166
|
+
res.json({ updated: store.moveMessages(req.agentId, input.message_ids, input.folder.toLowerCase()) });
|
|
167
|
+
});
|
|
168
|
+
api.post("/messages/delete", (req, res) => {
|
|
169
|
+
const input = deleteSchema.parse(req.body ?? {});
|
|
170
|
+
res.json({ deleted: store.deleteMessages(req.agentId, input.message_ids, input.permanent ?? false) });
|
|
171
|
+
});
|
|
172
|
+
api.get("/messages/:id", (req, res) => {
|
|
173
|
+
const markRead = req.query.mark_read !== "false";
|
|
174
|
+
res.json(store.getMessage(req.agentId, String(req.params.id), markRead));
|
|
175
|
+
});
|
|
176
|
+
api.get("/threads/:id", (req, res) => {
|
|
177
|
+
res.json({ thread_id: req.params.id, messages: store.getThread(req.agentId, String(req.params.id)) });
|
|
178
|
+
});
|
|
179
|
+
api.get("/folders", (req, res) => {
|
|
180
|
+
res.json({ folders: store.listFolders(req.agentId) });
|
|
181
|
+
});
|
|
182
|
+
api.post("/folders", (req, res) => {
|
|
183
|
+
const input = folderSchema.parse(req.body ?? {});
|
|
184
|
+
res.status(201).json({ folder: store.createFolder(req.agentId, input.name) });
|
|
185
|
+
});
|
|
186
|
+
api.delete("/folders/:name", (req, res) => {
|
|
187
|
+
res.json({ moved: store.deleteFolder(req.agentId, String(req.params.name)) });
|
|
188
|
+
});
|
|
189
|
+
// Attente longue : la réponse part dès qu'un message arrive, sinon au bout du délai.
|
|
190
|
+
api.get("/wait", (req, res) => {
|
|
191
|
+
const { timeout_seconds, folder } = waitQuerySchema.parse(req.query);
|
|
192
|
+
const agentId = req.agentId;
|
|
193
|
+
const timeoutSeconds = Math.min(timeout_seconds ?? 30, config.maxWaitSeconds);
|
|
194
|
+
const pending = store.listMessages(agentId, { folder: folder ?? "inbox", unread_only: true, limit: 1 });
|
|
195
|
+
const first = pending[0];
|
|
196
|
+
if (first) {
|
|
197
|
+
res.json({ timed_out: false, message: store.getMessage(agentId, first.message_id, false) });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const done = (payload) => {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
store.off("message", onMessage);
|
|
203
|
+
req.off("close", onClose);
|
|
204
|
+
if (!res.headersSent)
|
|
205
|
+
res.json(payload);
|
|
206
|
+
};
|
|
207
|
+
const onMessage = (event) => {
|
|
208
|
+
if (event.agent_id !== agentId)
|
|
209
|
+
return;
|
|
210
|
+
if (folder && event.folder !== folder)
|
|
211
|
+
return;
|
|
212
|
+
if (!folder && event.folder !== "inbox")
|
|
213
|
+
return;
|
|
214
|
+
done({ timed_out: false, message: store.getMessage(agentId, event.message_id, false) });
|
|
215
|
+
};
|
|
216
|
+
const onClose = () => {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
store.off("message", onMessage);
|
|
219
|
+
};
|
|
220
|
+
const timer = setTimeout(() => done({ timed_out: true, message: null }), timeoutSeconds * 1000);
|
|
221
|
+
store.on("message", onMessage);
|
|
222
|
+
req.once("close", onClose);
|
|
223
|
+
});
|
|
224
|
+
app.use("/api", api);
|
|
225
|
+
// --- Supervision (lecture seule) -----------------------------------------
|
|
226
|
+
const admin = express.Router();
|
|
227
|
+
admin.use(authenticateAdmin(config));
|
|
228
|
+
admin.get("/overview", (_req, res) => {
|
|
229
|
+
res.json(store.overview());
|
|
230
|
+
});
|
|
231
|
+
admin.get("/messages", (req, res) => {
|
|
232
|
+
const query = adminListSchema.parse(req.query);
|
|
233
|
+
res.json({ messages: store.listAllMessages(query) });
|
|
234
|
+
});
|
|
235
|
+
admin.get("/messages/:id", (req, res) => {
|
|
236
|
+
res.json(store.getMessageAdmin(String(req.params.id)));
|
|
237
|
+
});
|
|
238
|
+
admin.get("/threads/:id", (req, res) => {
|
|
239
|
+
const messages = store.listAllMessages({ thread_id: String(req.params.id), limit: 100 });
|
|
240
|
+
if (messages.length === 0)
|
|
241
|
+
throw new MessengerError(`thread not found: ${req.params.id}`, 404);
|
|
242
|
+
res.json({ thread_id: req.params.id, messages: messages.slice().reverse() });
|
|
243
|
+
});
|
|
244
|
+
app.use("/admin", admin);
|
|
245
|
+
// --- Interface web --------------------------------------------------------
|
|
246
|
+
// Un navigateur ne peut pas poser d'en-tête sur une navigation : la console s'ouvre
|
|
247
|
+
// donc avec `?token=`. La page est servie avec ce jeton propagé sur ses ressources,
|
|
248
|
+
// sans quoi le CSS et le script seraient refusés par la porte d'entrée.
|
|
249
|
+
app.get(["/ui", "/ui/", "/ui/index.html"], (req, res, next) => {
|
|
250
|
+
const token = presentedToken(req);
|
|
251
|
+
if (!token) {
|
|
252
|
+
next();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
fs.readFile(path.join(UI_DIR, "index.html"), "utf-8", (error, html) => {
|
|
256
|
+
if (error) {
|
|
257
|
+
next(error);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const suffix = `?token=${encodeURIComponent(token)}`;
|
|
261
|
+
res.type("html").send(html.replaceAll("./styles.css", `./styles.css${suffix}`).replaceAll("./app.js", `./app.js${suffix}`));
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
// `maxAge: 0` + ETag : la console reflète immédiatement une mise à jour du paquet,
|
|
265
|
+
// sans laisser un navigateur servir l'ancienne version depuis son cache.
|
|
266
|
+
app.use("/ui", express.static(UI_DIR, { index: false, maxAge: 0, etag: true }));
|
|
267
|
+
app.get("/", (req, res) => {
|
|
268
|
+
const token = presentedToken(req);
|
|
269
|
+
res.redirect(token ? `/ui/?token=${encodeURIComponent(token)}` : "/ui/");
|
|
270
|
+
});
|
|
271
|
+
// --- Erreurs --------------------------------------------------------------
|
|
272
|
+
app.use((_req, res) => {
|
|
273
|
+
res.status(404).json({ error: "not found" });
|
|
274
|
+
});
|
|
275
|
+
app.use((err, _req, res, _next) => {
|
|
276
|
+
if (err instanceof MessengerError) {
|
|
277
|
+
res.status(err.status).json({ error: err.message });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (err instanceof z.ZodError) {
|
|
281
|
+
res.status(400).json({ error: "invalid request", details: z.treeifyError(err) });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
logger.error("unhandled error", err);
|
|
285
|
+
// Le détail reste dans les logs : la réponse ne doit rien révéler de l'hôte.
|
|
286
|
+
res.status(500).json({ error: "internal server error" });
|
|
287
|
+
});
|
|
288
|
+
return app;
|
|
289
|
+
}
|
|
290
|
+
function toArray(value) {
|
|
291
|
+
if (value === undefined)
|
|
292
|
+
return [];
|
|
293
|
+
return Array.isArray(value) ? value : [value];
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Exige un jeton connu du serveur sur toute requête entrante.
|
|
297
|
+
*
|
|
298
|
+
* Accepte le jeton partagé, le jeton de la console, ou celui de n'importe quel agent :
|
|
299
|
+
* c'est le filtre grossier. La correspondance jeton ↔ agent est vérifiée juste après,
|
|
300
|
+
* route par route.
|
|
301
|
+
*/
|
|
302
|
+
function requireAnyToken(config) {
|
|
303
|
+
const accepted = [config.token, config.adminToken, ...config.agentTokens.values()].filter((value) => Boolean(value));
|
|
304
|
+
return (req, res, next) => {
|
|
305
|
+
const presented = presentedToken(req);
|
|
306
|
+
if (!presented || !accepted.some((candidate) => safeEqual(presented, candidate))) {
|
|
307
|
+
res.status(401).json({ error: "invalid or missing token" });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
next();
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
/** Identité de l'agent (en-tête `X-Agent-Id`) + jeton, selon la configuration du serveur. */
|
|
314
|
+
function authenticateAgent(config) {
|
|
315
|
+
return (req, res, next) => {
|
|
316
|
+
const agentId = normalizeAgentId(String(req.header("x-agent-id") ?? ""));
|
|
317
|
+
if (!agentId) {
|
|
318
|
+
res.status(401).json({ error: "missing X-Agent-Id header" });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (agentId.length > 128) {
|
|
322
|
+
res.status(400).json({ error: "agent id too long (max 128 chars)" });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const presented = presentedToken(req);
|
|
326
|
+
if (config.agentTokens.size > 0) {
|
|
327
|
+
const expected = config.agentTokens.get(agentId);
|
|
328
|
+
if (!expected || !presented || !safeEqual(presented, expected)) {
|
|
329
|
+
res.status(401).json({ error: "invalid credentials for this agent" });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
else if (config.token) {
|
|
334
|
+
if (!presented || !safeEqual(presented, config.token)) {
|
|
335
|
+
res.status(401).json({ error: "invalid or missing bearer token" });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
req.agentId = agentId;
|
|
340
|
+
next();
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function authenticateAdmin(config) {
|
|
344
|
+
return (req, res, next) => {
|
|
345
|
+
const presented = presentedToken(req);
|
|
346
|
+
if (!presented || !safeEqual(presented, config.adminToken)) {
|
|
347
|
+
res.status(401).json({ error: "invalid or missing admin token" });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
next();
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Jeton présenté par l'appelant, dans l'ordre de préférence : en-tête `Authorization`,
|
|
355
|
+
* en-tête `X-Messenger-Token`, puis paramètre `?token=` — ce dernier existe pour la
|
|
356
|
+
* navigation navigateur vers la console, qui ne peut pas poser d'en-tête.
|
|
357
|
+
*/
|
|
358
|
+
function presentedToken(req) {
|
|
359
|
+
const header = req.header("authorization");
|
|
360
|
+
if (header?.toLowerCase().startsWith("bearer "))
|
|
361
|
+
return header.slice(7).trim() || null;
|
|
362
|
+
const alt = req.header("x-messenger-token");
|
|
363
|
+
if (alt?.trim())
|
|
364
|
+
return alt.trim();
|
|
365
|
+
const query = req.query.token;
|
|
366
|
+
return typeof query === "string" && query.trim() ? query.trim() : null;
|
|
367
|
+
}
|
|
368
|
+
/** Comparaison à temps constant : évite de laisser fuiter le jeton octet par octet. */
|
|
369
|
+
function safeEqual(a, b) {
|
|
370
|
+
const bufA = Buffer.from(a);
|
|
371
|
+
const bufB = Buffer.from(b);
|
|
372
|
+
if (bufA.length !== bufB.length)
|
|
373
|
+
return false;
|
|
374
|
+
return timingSafeEqual(bufA, bufB);
|
|
375
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Server } from "node:http";
|
|
2
|
+
import { type ServerConfig } from "../config.js";
|
|
3
|
+
import { MessageStore } from "../core/store.js";
|
|
4
|
+
export interface RunningServer {
|
|
5
|
+
server: Server;
|
|
6
|
+
store: MessageStore;
|
|
7
|
+
config: ServerConfig;
|
|
8
|
+
url: string;
|
|
9
|
+
close: () => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/** Démarre la boîte aux lettres. `port: 0` laisse l'OS choisir (utilisé par les tests). */
|
|
12
|
+
export declare function startServer(config?: ServerConfig): Promise<RunningServer>;
|
|
13
|
+
/** Point d'entrée du mode serveur (`mcp-messenger --server`). */
|
|
14
|
+
export declare function runServerMode(): Promise<void>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createApp } from "./app.js";
|
|
2
|
+
import { loadServerConfig } from "../config.js";
|
|
3
|
+
import { MessageStore } from "../core/store.js";
|
|
4
|
+
import { logger } from "../logger.js";
|
|
5
|
+
import { APP_VERSION } from "../version.js";
|
|
6
|
+
/** Démarre la boîte aux lettres. `port: 0` laisse l'OS choisir (utilisé par les tests). */
|
|
7
|
+
export async function startServer(config = loadServerConfig()) {
|
|
8
|
+
const store = new MessageStore(config.dbPath);
|
|
9
|
+
const app = createApp({ store, config });
|
|
10
|
+
const server = await new Promise((resolve, reject) => {
|
|
11
|
+
const httpServer = app.listen(config.port, config.host, () => resolve(httpServer));
|
|
12
|
+
httpServer.once("error", reject);
|
|
13
|
+
});
|
|
14
|
+
const address = server.address();
|
|
15
|
+
const port = typeof address === "object" && address ? address.port : config.port;
|
|
16
|
+
const url = `http://${config.host}:${port}`;
|
|
17
|
+
// Purge quotidienne de la corbeille ; `unref` pour ne pas retenir le process.
|
|
18
|
+
const purgeTimer = setInterval(() => {
|
|
19
|
+
try {
|
|
20
|
+
const removed = store.purgeTrash(config.trashRetentionDays);
|
|
21
|
+
if (removed > 0)
|
|
22
|
+
logger.info(`purged ${removed} trashed deliveries`);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
logger.error("trash purge failed", error);
|
|
26
|
+
}
|
|
27
|
+
}, 24 * 3600_000);
|
|
28
|
+
purgeTimer.unref();
|
|
29
|
+
const close = async () => {
|
|
30
|
+
clearInterval(purgeTimer);
|
|
31
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
32
|
+
store.close();
|
|
33
|
+
};
|
|
34
|
+
return { server, store, config, url, close };
|
|
35
|
+
}
|
|
36
|
+
/** Point d'entrée du mode serveur (`mcp-messenger --server`). */
|
|
37
|
+
export async function runServerMode() {
|
|
38
|
+
const config = loadServerConfig();
|
|
39
|
+
const running = await startServer(config);
|
|
40
|
+
const authMode = config.agentTokens.size > 0
|
|
41
|
+
? `per-agent tokens (${config.agentTokens.size})${config.token ? " + shared token" : ""}`
|
|
42
|
+
: "shared token";
|
|
43
|
+
logger.info(`mcp-messenger v${APP_VERSION} listening on ${running.url} (loopback only)`);
|
|
44
|
+
logger.info(`database: ${config.dbPath}`);
|
|
45
|
+
logger.info(`auth: ${authMode} — required on every request`);
|
|
46
|
+
// Le jeton n'est pas écrit dans les logs : seule sa provenance est rappelée.
|
|
47
|
+
logger.info(`web console: ${running.url}/ui/?token=<MESSENGER_ADMIN_TOKEN>`);
|
|
48
|
+
let shuttingDown = false;
|
|
49
|
+
const shutdown = (signal) => {
|
|
50
|
+
if (shuttingDown)
|
|
51
|
+
return;
|
|
52
|
+
shuttingDown = true;
|
|
53
|
+
logger.info(`${signal} received, shutting down`);
|
|
54
|
+
running
|
|
55
|
+
.close()
|
|
56
|
+
.then(() => process.exit(0))
|
|
57
|
+
.catch((error) => {
|
|
58
|
+
logger.error("shutdown failed", error);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
63
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
64
|
+
}
|