@imenam/mcp-messenger 1.0.5 → 1.0.7
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 +6 -3
- package/README.md +63 -30
- package/dist/src/cli/generate-token.d.ts +2 -2
- package/dist/src/cli/generate-token.js +2 -2
- package/dist/src/cli/setup-mcp.js +2 -2
- package/dist/src/cli/watch.js +2 -2
- package/dist/src/client/messenger-client.d.ts +2 -3
- package/dist/src/client/messenger-client.js +3 -3
- package/dist/src/config.d.ts +3 -2
- package/dist/src/config.js +2 -2
- package/dist/src/core/store.d.ts +23 -8
- package/dist/src/core/store.js +65 -42
- package/dist/src/core/types.d.ts +19 -9
- package/dist/src/core/types.js +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/mcp/tools.js +102 -61
- package/dist/src/server/app.js +88 -72
- package/dist/src/server/console-session.d.ts +30 -0
- package/dist/src/server/console-session.js +85 -0
- package/dist/src/server/start.js +1 -2
- package/dist/src/server/ui/app.js +50 -25
- package/dist/src/server/ui/index.html +5 -4
- package/dist/src/server/ui/styles.css +6 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Request } from "express";
|
|
2
|
+
/**
|
|
3
|
+
* Sessions de la console web.
|
|
4
|
+
*
|
|
5
|
+
* Le navigateur ne peut pas poser d'en-tête sur une navigation, et faire transiter le
|
|
6
|
+
* jeton dans l'URL le dépose dans l'historique et les journaux d'accès du proxy. La
|
|
7
|
+
* console s'ouvre donc sans jeton, le demande une fois dans un formulaire, et reçoit en
|
|
8
|
+
* retour un identifiant de session opaque dans un cookie `HttpOnly`.
|
|
9
|
+
*
|
|
10
|
+
* Le cookie ne porte jamais le jeton lui-même : sa fuite ne donne qu'une session, à durée
|
|
11
|
+
* limitée, que le serveur oublie au redémarrage. Il n'est honoré que sur `/admin` et
|
|
12
|
+
* `/ui` : l'API des agents reste sur le Bearer, un site tiers ne peut donc pas faire
|
|
13
|
+
* agir un agent à travers le navigateur de l'administrateur.
|
|
14
|
+
*/
|
|
15
|
+
export declare const CONSOLE_COOKIE = "messenger_console";
|
|
16
|
+
export declare class ConsoleSessions {
|
|
17
|
+
readonly ttlMs: number;
|
|
18
|
+
private readonly now;
|
|
19
|
+
private readonly sessions;
|
|
20
|
+
constructor(ttlMs?: number, now?: () => number);
|
|
21
|
+
/** Ouvre une session et rend son identifiant, seul contenu du cookie. */
|
|
22
|
+
create(): string;
|
|
23
|
+
/** Vrai si l'identifiant désigne une session encore valide. */
|
|
24
|
+
has(id: string | null): boolean;
|
|
25
|
+
revoke(id: string | null): void;
|
|
26
|
+
get size(): number;
|
|
27
|
+
private prune;
|
|
28
|
+
}
|
|
29
|
+
/** Identifiant de session porté par la requête, s'il y en a un. */
|
|
30
|
+
export declare function consoleSessionId(req: Request): string | null;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Sessions de la console web.
|
|
4
|
+
*
|
|
5
|
+
* Le navigateur ne peut pas poser d'en-tête sur une navigation, et faire transiter le
|
|
6
|
+
* jeton dans l'URL le dépose dans l'historique et les journaux d'accès du proxy. La
|
|
7
|
+
* console s'ouvre donc sans jeton, le demande une fois dans un formulaire, et reçoit en
|
|
8
|
+
* retour un identifiant de session opaque dans un cookie `HttpOnly`.
|
|
9
|
+
*
|
|
10
|
+
* Le cookie ne porte jamais le jeton lui-même : sa fuite ne donne qu'une session, à durée
|
|
11
|
+
* limitée, que le serveur oublie au redémarrage. Il n'est honoré que sur `/admin` et
|
|
12
|
+
* `/ui` : l'API des agents reste sur le Bearer, un site tiers ne peut donc pas faire
|
|
13
|
+
* agir un agent à travers le navigateur de l'administrateur.
|
|
14
|
+
*/
|
|
15
|
+
export const CONSOLE_COOKIE = "messenger_console";
|
|
16
|
+
/** Durée de vie d'une session : assez pour une journée de supervision, pas plus. */
|
|
17
|
+
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
18
|
+
export class ConsoleSessions {
|
|
19
|
+
ttlMs;
|
|
20
|
+
now;
|
|
21
|
+
sessions = new Map();
|
|
22
|
+
constructor(ttlMs = SESSION_TTL_MS, now = Date.now) {
|
|
23
|
+
this.ttlMs = ttlMs;
|
|
24
|
+
this.now = now;
|
|
25
|
+
}
|
|
26
|
+
/** Ouvre une session et rend son identifiant, seul contenu du cookie. */
|
|
27
|
+
create() {
|
|
28
|
+
this.prune();
|
|
29
|
+
const id = randomBytes(32).toString("base64url");
|
|
30
|
+
this.sessions.set(id, { expiresAt: this.now() + this.ttlMs });
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
/** Vrai si l'identifiant désigne une session encore valide. */
|
|
34
|
+
has(id) {
|
|
35
|
+
if (!id)
|
|
36
|
+
return false;
|
|
37
|
+
// Parcours à temps constant : la Map compare ses clés en temps variable, ce qui
|
|
38
|
+
// laisserait deviner un identifiant octet par octet.
|
|
39
|
+
let match = null;
|
|
40
|
+
const presented = Buffer.from(id);
|
|
41
|
+
for (const [key, session] of this.sessions) {
|
|
42
|
+
const candidate = Buffer.from(key);
|
|
43
|
+
if (candidate.length === presented.length && timingSafeEqual(candidate, presented))
|
|
44
|
+
match = session;
|
|
45
|
+
}
|
|
46
|
+
if (!match)
|
|
47
|
+
return false;
|
|
48
|
+
if (match.expiresAt <= this.now()) {
|
|
49
|
+
this.sessions.delete(id);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
revoke(id) {
|
|
55
|
+
if (id)
|
|
56
|
+
this.sessions.delete(id);
|
|
57
|
+
}
|
|
58
|
+
get size() {
|
|
59
|
+
this.prune();
|
|
60
|
+
return this.sessions.size;
|
|
61
|
+
}
|
|
62
|
+
prune() {
|
|
63
|
+
const now = this.now();
|
|
64
|
+
for (const [id, session] of this.sessions) {
|
|
65
|
+
if (session.expiresAt <= now)
|
|
66
|
+
this.sessions.delete(id);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Identifiant de session porté par la requête, s'il y en a un. */
|
|
71
|
+
export function consoleSessionId(req) {
|
|
72
|
+
const header = req.headers.cookie;
|
|
73
|
+
if (!header)
|
|
74
|
+
return null;
|
|
75
|
+
for (const part of header.split(";")) {
|
|
76
|
+
const separator = part.indexOf("=");
|
|
77
|
+
if (separator < 0)
|
|
78
|
+
continue;
|
|
79
|
+
if (part.slice(0, separator).trim() !== CONSOLE_COOKIE)
|
|
80
|
+
continue;
|
|
81
|
+
const value = part.slice(separator + 1).trim();
|
|
82
|
+
return value ? decodeURIComponent(value) : null;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
package/dist/src/server/start.js
CHANGED
|
@@ -43,8 +43,7 @@ export async function runServerMode() {
|
|
|
43
43
|
logger.info(`mcp-messenger v${APP_VERSION} listening on ${running.url} (loopback only)`);
|
|
44
44
|
logger.info(`database: ${config.dbPath}`);
|
|
45
45
|
logger.info(`auth: ${authMode} — required on every request`);
|
|
46
|
-
|
|
47
|
-
logger.info(`web console: ${running.url}/ui/?token=<MESSENGER_ADMIN_TOKEN>`);
|
|
46
|
+
logger.info(`web console: ${running.url}/ui/ (asks for MESSENGER_ADMIN_TOKEN once)`);
|
|
48
47
|
let shuttingDown = false;
|
|
49
48
|
const shutdown = (signal) => {
|
|
50
49
|
if (shuttingDown)
|
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
// Console de supervision : lecture seule sur les routes /admin du serveur.
|
|
2
2
|
// Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement.
|
|
3
3
|
|
|
4
|
-
const TOKEN_KEY = "messenger.admin.token";
|
|
5
4
|
const REFRESH_MS = 5000;
|
|
6
5
|
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
const TOKEN_FROM_URL = new URL(import.meta.url).searchParams.get("token");
|
|
6
|
+
// L'authentification tient dans un cookie de session HttpOnly posé par `/ui/login` :
|
|
7
|
+
// ce script ne voit jamais le jeton, et ne stocke rien dans le navigateur.
|
|
10
8
|
|
|
11
9
|
const state = {
|
|
12
|
-
token: TOKEN_FROM_URL || localStorage.getItem(TOKEN_KEY) || "",
|
|
13
10
|
agent: null,
|
|
14
11
|
search: "",
|
|
15
12
|
selectedMessageId: null,
|
|
@@ -28,10 +25,10 @@ const el = {
|
|
|
28
25
|
clearFilters: document.getElementById("clear-filters"),
|
|
29
26
|
refresh: document.getElementById("refresh"),
|
|
30
27
|
autoRefresh: document.getElementById("auto-refresh"),
|
|
31
|
-
|
|
28
|
+
logout: document.getElementById("logout"),
|
|
29
|
+
loginForm: document.getElementById("login-form"),
|
|
32
30
|
tokenInput: document.getElementById("token"),
|
|
33
31
|
tokenError: document.getElementById("token-error"),
|
|
34
|
-
saveToken: document.getElementById("save-token"),
|
|
35
32
|
};
|
|
36
33
|
|
|
37
34
|
async function api(path, params) {
|
|
@@ -39,12 +36,10 @@ async function api(path, params) {
|
|
|
39
36
|
for (const [key, value] of Object.entries(params || {})) {
|
|
40
37
|
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, value);
|
|
41
38
|
}
|
|
42
|
-
const headers = { accept: "application/json" };
|
|
43
|
-
if (state.token) headers.authorization = "Bearer " + state.token;
|
|
44
39
|
|
|
45
|
-
const response = await fetch(url, { headers });
|
|
40
|
+
const response = await fetch(url, { headers: { accept: "application/json" }, credentials: "same-origin" });
|
|
46
41
|
if (response.status === 401) {
|
|
47
|
-
|
|
42
|
+
setLoggedIn(false);
|
|
48
43
|
throw new Error("unauthorized");
|
|
49
44
|
}
|
|
50
45
|
if (!response.ok) {
|
|
@@ -54,6 +49,12 @@ async function api(path, params) {
|
|
|
54
49
|
return response.json();
|
|
55
50
|
}
|
|
56
51
|
|
|
52
|
+
function setLoggedIn(loggedIn) {
|
|
53
|
+
el.loginForm.hidden = loggedIn;
|
|
54
|
+
el.logout.hidden = !loggedIn;
|
|
55
|
+
if (!loggedIn) el.tokenInput.focus();
|
|
56
|
+
}
|
|
57
|
+
|
|
57
58
|
/** Remplace le contenu du panneau de détail (et retire l'état « vide » hérité du HTML). */
|
|
58
59
|
function setDetail(child, empty) {
|
|
59
60
|
el.detail.className = empty ? "empty" : "detail-content";
|
|
@@ -86,8 +87,11 @@ function renderAgents(overview) {
|
|
|
86
87
|
|
|
87
88
|
for (const agent of overview.agents) {
|
|
88
89
|
const item = node("li", state.agent === agent.id ? "selected" : "");
|
|
89
|
-
//
|
|
90
|
-
|
|
90
|
+
// Deux noms, deux questions : l'agent dit d'où l'on écrit (le projet), la boîte de quoi
|
|
91
|
+
// cette session s'occupe. L'un sans l'autre ne suffit pas à s'y retrouver.
|
|
92
|
+
// L'adresse ne dit rien à l'utilisateur, elle reste là pour être copiée.
|
|
93
|
+
item.appendChild(node("div", "name", agent.agent_name || "agent sans nom"));
|
|
94
|
+
item.appendChild(node("div", "mailbox-name", agent.display_name || "boîte sans nom"));
|
|
91
95
|
item.appendChild(node("div", "id", agent.id));
|
|
92
96
|
item.appendChild(
|
|
93
97
|
node("div", "muted", "envoyés " + agent.sent + " · reçus " + agent.received + " · non lus " + agent.unread)
|
|
@@ -131,7 +135,7 @@ function renderMessages(messages) {
|
|
|
131
135
|
// Le nom du fil dit de quelle conversation relève le message ; sans nom, rien à afficher.
|
|
132
136
|
if (message.thread_title) item.appendChild(node("div", "thread-name", message.thread_title));
|
|
133
137
|
item.appendChild(
|
|
134
|
-
node("div", "route", message.from + " → " + (message.to
|
|
138
|
+
node("div", "route", message.from + " → " + (message.to || "—") + " · " + formatDate(message.created_at))
|
|
135
139
|
);
|
|
136
140
|
item.appendChild(node("div", "preview", message.preview));
|
|
137
141
|
item.addEventListener("click", () => openMessage(message.message_id));
|
|
@@ -160,8 +164,7 @@ function renderDetail(message) {
|
|
|
160
164
|
"De " +
|
|
161
165
|
message.from +
|
|
162
166
|
" · À " +
|
|
163
|
-
(message.to
|
|
164
|
-
(message.cc.length ? " · Cc " + message.cc.join(", ") : "") +
|
|
167
|
+
(message.to || "—") +
|
|
165
168
|
"\n" +
|
|
166
169
|
formatDate(message.created_at) +
|
|
167
170
|
" · priorité " +
|
|
@@ -225,7 +228,7 @@ async function showThread(threadId) {
|
|
|
225
228
|
const block = node("div", "thread-message");
|
|
226
229
|
block.appendChild(node("div", "subject", message.subject));
|
|
227
230
|
block.appendChild(
|
|
228
|
-
node("div", "meta", message.from + " → " + (message.to
|
|
231
|
+
node("div", "meta", message.from + " → " + (message.to || "—") + " · " + formatDate(message.created_at))
|
|
229
232
|
);
|
|
230
233
|
block.appendChild(node("div", "preview", message.preview));
|
|
231
234
|
const openButton = node("button", "link", "ouvrir");
|
|
@@ -245,7 +248,7 @@ async function refresh(options) {
|
|
|
245
248
|
api("/admin/overview"),
|
|
246
249
|
api("/admin/messages", { agent: state.agent, search: state.search, thread_id: state.threadId, limit: 100 }),
|
|
247
250
|
]);
|
|
248
|
-
|
|
251
|
+
setLoggedIn(true);
|
|
249
252
|
renderAgents(overview);
|
|
250
253
|
renderMessages(list.messages);
|
|
251
254
|
if (!options || !options.keepDetail) {
|
|
@@ -279,17 +282,39 @@ el.search.addEventListener("input", (event) => {
|
|
|
279
282
|
searchTimer = setTimeout(() => refresh({ keepDetail: true }), 250);
|
|
280
283
|
});
|
|
281
284
|
|
|
282
|
-
el.
|
|
283
|
-
|
|
284
|
-
localStorage.setItem(TOKEN_KEY, state.token);
|
|
285
|
+
el.loginForm.addEventListener("submit", async (event) => {
|
|
286
|
+
event.preventDefault();
|
|
285
287
|
el.tokenError.textContent = "";
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
288
|
+
const token = el.tokenInput.value.trim();
|
|
289
|
+
if (!token) return;
|
|
290
|
+
const response = await fetch("/ui/login", {
|
|
291
|
+
method: "POST",
|
|
292
|
+
headers: { "content-type": "application/json" },
|
|
293
|
+
credentials: "same-origin",
|
|
294
|
+
body: JSON.stringify({ token }),
|
|
295
|
+
}).catch(() => null);
|
|
296
|
+
// Le champ est vidé dans tous les cas : le jeton ne reste pas affiché dans la page.
|
|
297
|
+
el.tokenInput.value = "";
|
|
298
|
+
if (!response || !response.ok) {
|
|
299
|
+
el.tokenError.textContent = response && response.status === 429 ? "trop de tentatives, réessayez plus tard" : "jeton refusé";
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
refresh();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
el.logout.addEventListener("click", async () => {
|
|
306
|
+
await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
|
|
307
|
+
setLoggedIn(false);
|
|
308
|
+
el.statusDot.className = "dot";
|
|
309
|
+
el.serverStats.textContent = "déconnecté";
|
|
310
|
+
el.agents.replaceChildren();
|
|
311
|
+
el.messages.replaceChildren();
|
|
312
|
+
state.selectedMessageId = null;
|
|
313
|
+
setDetail(node("div", null, "Sélectionnez un message pour voir son contenu."), true);
|
|
289
314
|
});
|
|
290
315
|
|
|
291
316
|
setInterval(() => {
|
|
292
|
-
if (el.autoRefresh.checked) refresh({ keepDetail: true });
|
|
317
|
+
if (el.autoRefresh.checked && el.loginForm.hidden) refresh({ keepDetail: true });
|
|
293
318
|
}, REFRESH_MS);
|
|
294
319
|
|
|
295
320
|
refresh();
|
|
@@ -19,15 +19,16 @@
|
|
|
19
19
|
rafraîchissement auto
|
|
20
20
|
</label>
|
|
21
21
|
<button id="refresh">Rafraîchir</button>
|
|
22
|
+
<button id="logout" hidden>Se déconnecter</button>
|
|
22
23
|
</div>
|
|
23
24
|
</header>
|
|
24
25
|
|
|
25
|
-
<
|
|
26
|
+
<form id="login-form" class="token-bar" hidden>
|
|
26
27
|
<label for="token">Jeton d'accès requis</label>
|
|
27
|
-
<input type="password" id="token" placeholder="MESSENGER_ADMIN_TOKEN
|
|
28
|
-
<button
|
|
28
|
+
<input type="password" id="token" autocomplete="current-password" placeholder="MESSENGER_ADMIN_TOKEN" />
|
|
29
|
+
<button type="submit">Se connecter</button>
|
|
29
30
|
<span class="error" id="token-error"></span>
|
|
30
|
-
</
|
|
31
|
+
</form>
|
|
31
32
|
|
|
32
33
|
<main>
|
|
33
34
|
<aside class="panel agents">
|
|
@@ -121,6 +121,7 @@ input[type="password"] {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
.token-bar {
|
|
124
|
+
margin: 0;
|
|
124
125
|
display: flex;
|
|
125
126
|
align-items: center;
|
|
126
127
|
gap: 10px;
|
|
@@ -184,6 +185,11 @@ ul {
|
|
|
184
185
|
font-weight: 600;
|
|
185
186
|
}
|
|
186
187
|
|
|
188
|
+
/* Le nom de la boîte, sous celui de l'agent : le sujet de la session, sans lui voler la vedette. */
|
|
189
|
+
.agents .mailbox-name {
|
|
190
|
+
font-size: 12px;
|
|
191
|
+
}
|
|
192
|
+
|
|
187
193
|
/* L'adresse reste lisible pour être recopiée, mais ne prend plus le pas sur le nom. */
|
|
188
194
|
.agents .id {
|
|
189
195
|
color: var(--muted);
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const APP_VERSION = "1.0.
|
|
1
|
+
export declare const APP_VERSION = "1.0.7";
|
package/dist/src/version.js
CHANGED
|
@@ -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.
|
|
2
|
+
export const APP_VERSION = "1.0.7";
|