@fkom13/mcp-sftp-orchestrator 6.0.0 → 11.3.0

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/server.js CHANGED
@@ -1,4 +1,5 @@
1
- #!/usr/bin/env node
1
+ const DEBUG = process.env.MCP_DEBUG === 'true';
2
+
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { z } from "zod";
@@ -9,14 +10,87 @@ import ssh from './ssh.js';
9
10
  import history from './history.js';
10
11
  import config from './config.js';
11
12
  import apis from './apis.js';
13
+ import sshPool from './sshPool.js';
14
+ import utils from './utils.js';
15
+ import fileOps from './fileOps.js';
16
+ import diffEngine from './diffEngine.js';
17
+ import compareEngine from './compareEngine.js';
18
+ import shellSessions from './shellSessions.js';
19
+ import snapshotManager from './snapshotManager.js';
20
+ import diffFormatter from './diffFormatter.js';
21
+ import notes from './notes.js';
22
+ import guide from './guide.js';
23
+ import policies from './policies.js';
24
+ import tunnels from './tunnels.js';
25
+ import { sourceSchema } from './sourceAdapter.js';
26
+
27
+ if (DEBUG) {
28
+ console.error("✅ Tous les modules importés");
29
+ console.error("⏳ Initialisation de la queue...");
30
+ }
31
+
32
+ await queue.init();
33
+
34
+ if (DEBUG) console.error("✅ Queue initialisée");
35
+
36
+ // Vérification des secrets en clair
37
+ try {
38
+ const serverList = await servers.listServers();
39
+ for (const [alias, config] of Object.entries(serverList)) {
40
+ if (config.password) {
41
+ console.error(`[SECURITY WARN] Mot de passe en clair détecté pour le serveur '${alias}'. Utilisez une clé SSH ou Vaultwarden.`);
42
+ }
43
+ }
44
+ const apiList = await apis.listApis();
45
+ for (const [alias, config] of Object.entries(apiList)) {
46
+ if (config.api_key || config.htpasswd_pass) {
47
+ console.error(`[SECURITY WARN] Secret en clair détecté pour l'API '${alias}'. Utilisez Vaultwarden.`);
48
+ }
49
+ }
50
+ } catch (e) { /* silencieux, pas bloquant */ }
12
51
 
13
52
  const server = new McpServer({
14
53
  name: "orchestrator",
15
- version: "8.0.0", // Version avec gestion du streaming
54
+ version: "11.3.0",
16
55
  description: "Serveur pour l'orchestration de tâches distantes avec exécution hybride et configuration flexible."
17
56
  });
18
57
 
58
+ if (DEBUG) console.error("✅ Serveur MCP créé");
59
+
60
+ // Restaurer les tunnels locaux au démarrage
61
+ tunnels.restore(servers).then(n => {
62
+ if (n > 0) queue.log('info', `${n} tunnels locaux restaurés.`);
63
+ }).catch(() => {});
64
+
65
+ server.registerTool(
66
+ "system_diagnostics",
67
+ {
68
+ title: "Diagnostic système complet",
69
+ description: "Exécute un diagnostic complet du système MCP.",
70
+ inputSchema: z.object({
71
+ verbose: z.boolean().optional().default(false)
72
+ })
73
+ },
74
+ async (params) => {
75
+ const stats = {
76
+ queue: queue.getStats(),
77
+ pool: ssh.getPoolStats(),
78
+ servers: await servers.listServers(),
79
+ apis: await apis.listApis(),
80
+ crashed: queue.getCrashedJobs().length
81
+ };
82
+
83
+ if (params.verbose) {
84
+ stats.logs = queue.getLogs({ limit: 20 });
85
+ }
86
+
87
+ return { content: [{ type: "text", text: JSON.stringify(stats, null, 2) }] };
88
+ }
89
+ );
90
+
91
+
19
92
  // --- OUTILS DE GESTION DES SERVEURS ---
93
+
20
94
  server.registerTool(
21
95
  "server_add",
22
96
  {
@@ -53,7 +127,7 @@ server.registerTool(
53
127
  {
54
128
  title: "Lister les alias de serveurs",
55
129
  description: "Affiche la liste de tous les alias de serveurs configurés avec leurs détails.",
56
- inputSchema: {}
130
+ inputSchema: z.object({})
57
131
  },
58
132
  async () => {
59
133
  const serverList = await servers.listServers();
@@ -66,9 +140,9 @@ server.registerTool(
66
140
  {
67
141
  title: "Supprimer un alias de serveur",
68
142
  description: "Supprime un alias de serveur de la configuration.",
69
- inputSchema: {
143
+ inputSchema: z.object({
70
144
  alias: z.string().describe("Nom de l'alias à supprimer")
71
- }
145
+ })
72
146
  },
73
147
  async (params) => {
74
148
  try {
@@ -85,13 +159,58 @@ server.registerTool(
85
159
  }
86
160
  );
87
161
 
162
+ // --- OUTILS DE SÉCURITÉ / POLICIES ---
163
+
164
+ server.registerTool(
165
+ "policy_blocklist_list",
166
+ {
167
+ title: "Lister les commandes bloquées",
168
+ description: "Affiche la liste des patterns de commandes bloquées par la politique de sécurité.",
169
+ inputSchema: z.object({})
170
+ },
171
+ async () => {
172
+ const policiesData = await policies.list();
173
+ return { content: [{ type: "text", text: JSON.stringify(policiesData, null, 2) }] };
174
+ }
175
+ );
176
+
177
+ server.registerTool(
178
+ "policy_blocklist_add",
179
+ {
180
+ title: "Ajouter une commande à la blocklist",
181
+ description: "Ajoute un pattern de commande à la liste noire. Le pattern peut contenir * comme joker.",
182
+ inputSchema: z.object({
183
+ pattern: z.string().describe("Pattern de commande à bloquer (ex: 'rm -rf /' ou 'wget * | sh')")
184
+ })
185
+ },
186
+ async (params) => {
187
+ const policiesData = await policies.add(params.pattern);
188
+ return { content: [{ type: "text", text: `Pattern '${params.pattern}' ajouté à la blocklist.\n` + JSON.stringify(policiesData, null, 2) }] };
189
+ }
190
+ );
191
+
192
+ server.registerTool(
193
+ "policy_blocklist_remove",
194
+ {
195
+ title: "Retirer une commande de la blocklist",
196
+ description: "Supprime un pattern de commande de la liste noire.",
197
+ inputSchema: z.object({
198
+ pattern: z.string().describe("Pattern à retirer de la blocklist")
199
+ })
200
+ },
201
+ async (params) => {
202
+ const policiesData = await policies.remove(params.pattern);
203
+ return { content: [{ type: "text", text: `Pattern '${params.pattern}' retiré de la blocklist.\n` + JSON.stringify(policiesData, null, 2) }] };
204
+ }
205
+ );
206
+
88
207
  // --- OUTILS DE GESTION D'API ---
89
208
  server.registerTool(
90
209
  "api_add",
91
210
  {
92
211
  title: "Ajouter une API au catalogue",
93
212
  description: "Ajoute ou met à jour une API dans le catalogue de monitoring.",
94
- inputSchema: {
213
+ inputSchema: z.object({
95
214
  alias: z.string().describe("Alias unique pour l'API."),
96
215
  url: z.string().url().describe("URL de base de l'API, incluant le port si nécessaire."),
97
216
  health_check_endpoint: z.string().optional().describe("Endpoint spécifique pour le test de santé (ex: /health)."),
@@ -103,7 +222,7 @@ server.registerTool(
103
222
  htpasswd_user: z.string().optional().describe("Nom d'utilisateur pour l'authentification Basic (htpasswd)."),
104
223
  htpasswd_pass: z.string().optional().describe("Mot de passe pour l'authentification Basic (htpasswd)."),
105
224
  notes: z.string().optional().describe("Notes additionnelles.")
106
- }
225
+ })
107
226
  },
108
227
  async (params) => {
109
228
  try {
@@ -111,7 +230,12 @@ server.registerTool(
111
230
  const result = await apis.addApi(alias, apiConfig);
112
231
  return { content: [{ type: "text", text: result.message }] };
113
232
  } catch (e) {
114
- return { content: [{ type: "text", text: `ERREUR: ${e.message}` }], isError: true };
233
+ const errorPayload = {
234
+ toolName: "api_add",
235
+ errorCode: "TOOL_EXECUTION_ERROR",
236
+ errorMessage: e.message
237
+ };
238
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
115
239
  }
116
240
  }
117
241
  );
@@ -121,7 +245,7 @@ server.registerTool(
121
245
  {
122
246
  title: "Lister les APIs du catalogue",
123
247
  description: "Affiche toutes les APIs configurées dans le catalogue.",
124
- inputSchema: {}
248
+ inputSchema: z.object({})
125
249
  },
126
250
  async () => {
127
251
  const apiList = await apis.listApis();
@@ -134,16 +258,21 @@ server.registerTool(
134
258
  {
135
259
  title: "Supprimer une API du catalogue",
136
260
  description: "Supprime une API du catalogue en utilisant son alias.",
137
- inputSchema: {
261
+ inputSchema: z.object({
138
262
  alias: z.string().describe("Alias de l'API à supprimer.")
139
- }
263
+ })
140
264
  },
141
265
  async (params) => {
142
266
  try {
143
267
  const result = await apis.removeApi(params.alias);
144
268
  return { content: [{ type: "text", text: result.message }] };
145
269
  } catch (e) {
146
- return { content: [{ type: "text", text: `ERREUR: ${e.message}` }], isError: true };
270
+ const errorPayload = {
271
+ toolName: "api_remove",
272
+ errorCode: "TOOL_EXECUTION_ERROR",
273
+ errorMessage: e.message
274
+ };
275
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
147
276
  }
148
277
  }
149
278
  );
@@ -153,10 +282,10 @@ server.registerTool(
153
282
  {
154
283
  title: "Vérifier la santé d'une API via son alias",
155
284
  description: "Lance un test de santé sur une API du catalogue.",
156
- inputSchema: {
285
+ inputSchema: z.object({
157
286
  alias: z.string().describe("Alias de l'API à tester."),
158
287
  server_alias: z.string().describe("Alias du serveur depuis lequel lancer le test.")
159
- }
288
+ })
160
289
  },
161
290
  async (params) => {
162
291
  try {
@@ -164,21 +293,23 @@ server.registerTool(
164
293
  const endpoint = apiConfig.health_check_endpoint || '';
165
294
  const url = `${apiConfig.url}${endpoint}`;
166
295
  const method = apiConfig.health_check_method || 'GET';
167
-
296
+
168
297
  let curlCmd = `curl -X ${method} -o /dev/null -s -w '%{http_code}:%{time_total}'`;
169
298
 
170
299
  // Gérer l'authentification htpasswd
171
300
  if ((apiConfig.auth_method === 'htpasswd' || apiConfig.auth_method === 'both') && apiConfig.htpasswd_user && apiConfig.htpasswd_pass) {
172
- curlCmd += ` -u ${apiConfig.htpasswd_user}:${apiConfig.htpasswd_pass}`;
301
+ const credentials = `${apiConfig.htpasswd_user}:${apiConfig.htpasswd_pass}`;
302
+ curlCmd += ` -u ${utils.escapeShellArg(credentials)}`;
173
303
  }
174
304
 
175
305
  // Gérer l'authentification par clé API
176
306
  if ((apiConfig.auth_method === 'api_key' || apiConfig.auth_method === 'both') && apiConfig.api_key) {
177
307
  const scheme = apiConfig.auth_scheme ? `${apiConfig.auth_scheme} ` : '';
178
- curlCmd += ` -H '${apiConfig.auth_header_name || 'Authorization'}: ${scheme}${apiConfig.api_key}'`;
308
+ const headerValue = `${apiConfig.auth_header_name || 'Authorization'}: ${scheme}${apiConfig.api_key}`;
309
+ curlCmd += ` -H ${utils.escapeShellArg(headerValue)}`;
179
310
  }
180
-
181
- curlCmd += ` ${url}`;
311
+
312
+ curlCmd += ` ${utils.escapeShellArg(url)}`;
182
313
 
183
314
  const job = queue.addJob({ type: 'ssh', alias: params.server_alias, cmd: curlCmd });
184
315
  ssh.executeCommand(job.id);
@@ -191,7 +322,12 @@ server.registerTool(
191
322
  const parsedOutput = ssh.parseApiHealth(result.output);
192
323
  return { content: [{ type: "text", text: JSON.stringify(parsedOutput, null, 2) }] };
193
324
  } catch (e) {
194
- return { content: [{ type: "text", text: `ERREUR: ${e.message}` }], isError: true };
325
+ const errorPayload = {
326
+ toolName: "api_check",
327
+ errorCode: "TOOL_EXECUTION_ERROR",
328
+ errorMessage: e.message
329
+ };
330
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
195
331
  }
196
332
  }
197
333
  );
@@ -202,9 +338,9 @@ server.registerTool(
202
338
  {
203
339
  title: "Obtenir les ressources système d'un VPS",
204
340
  description: "Récupère les métriques système vitales (CPU, RAM, Disque) d'un serveur.",
205
- inputSchema: {
341
+ inputSchema: z.object({
206
342
  alias: z.string().describe("Alias du serveur cible.")
207
- }
343
+ })
208
344
  },
209
345
  async (params) => {
210
346
  try {
@@ -238,13 +374,13 @@ server.registerTool(
238
374
  {
239
375
  title: "Obtenir le statut des services d'un VPS",
240
376
  description: "Récupère le statut de tous les services connus (systemd, Docker, PM2) sur un serveur.",
241
- inputSchema: {
377
+ inputSchema: z.object({
242
378
  alias: z.string().describe("Alias du serveur cible.")
243
- }
379
+ })
244
380
  },
245
381
  async (params) => {
246
382
  try {
247
- const cmd = "systemctl --type=service --state=running --no-pager ; echo '---DOCKER---' ; docker ps --format '{{.Names}}: {{.Status}}' ; echo '---PM2---' ; pm2 list";
383
+ const cmd = "echo '---SYSTEMD---' ; systemctl --type=service --state=running --no-pager 2>/dev/null || echo '(systemd indisponible)' ; echo '---DOCKER---' ; if command -v docker &>/dev/null; then docker ps --format '{{.Names}}: {{.Status}}' 2>/dev/null || echo '(Docker arrêté)'; else echo '(Docker non installé)'; fi ; echo '---PM2---' ; if command -v pm2 &>/dev/null; then pm2 list 2>/dev/null || echo '(PM2 arrêté)'; else echo '(PM2 non installé)'; fi";
248
384
  const job = queue.addJob({
249
385
  type: 'ssh',
250
386
  alias: params.alias,
@@ -275,14 +411,14 @@ server.registerTool(
275
411
  {
276
412
  title: "Vérifier la santé d'une API",
277
413
  description: "Vérifie la disponibilité et le temps de réponse d'un endpoint HTTP/S.",
278
- inputSchema: {
414
+ inputSchema: z.object({
279
415
  alias: z.string().describe("Alias du serveur depuis lequel lancer le test."),
280
416
  url: z.string().url().describe("URL complète de l'endpoint à tester.")
281
- }
417
+ })
282
418
  },
283
419
  async (params) => {
284
420
  try {
285
- const cmd = `curl -o /dev/null -s -w '%{http_code}:%{time_total}' ${params.url}`;
421
+ const cmd = `curl -o /dev/null -s -w '%{http_code}:%{time_total}' ${utils.escapeShellArg(params.url)}`;
286
422
  const job = queue.addJob({
287
423
  type: 'ssh',
288
424
  alias: params.alias,
@@ -313,10 +449,10 @@ server.registerTool(
313
449
  {
314
450
  title: "Obtenir le statut de Fail2Ban",
315
451
  description: "Récupère les informations du service Fail2Ban, pour toutes les jails ou une jail spécifique.",
316
- inputSchema: {
452
+ inputSchema: z.object({
317
453
  alias: z.string().describe("Alias du serveur cible."),
318
454
  jail: z.string().optional().describe("Nom d'une jail spécifique à inspecter (ex: sshd). Laissez vide pour un statut général.")
319
- }
455
+ })
320
456
  },
321
457
  async (params) => {
322
458
  try {
@@ -347,16 +483,22 @@ server.registerTool(
347
483
  );
348
484
 
349
485
 
486
+ // --- LOGIQUE D'ATTENTE HYBRIDE ---
350
487
  // --- LOGIQUE D'ATTENTE HYBRIDE ---
351
488
  async function waitForJobCompletion(jobId, timeout) {
352
489
  return new Promise((resolve) => {
353
490
  const startTime = Date.now();
354
491
  const interval = setInterval(() => {
355
492
  const job = queue.getJob(jobId);
493
+ if (!job) {
494
+ clearInterval(interval);
495
+ resolve(null);
496
+ return;
497
+ }
356
498
  if (job.status === 'completed' || job.status === 'failed') {
357
499
  clearInterval(interval);
358
500
  resolve(job);
359
- } else if (Date.now() - startTime > timeout) {
501
+ } else if (timeout > 0 && Date.now() - startTime > timeout) {
360
502
  clearInterval(interval);
361
503
  resolve(null);
362
504
  }
@@ -364,19 +506,26 @@ async function waitForJobCompletion(jobId, timeout) {
364
506
  });
365
507
  }
366
508
 
509
+ function buildAsyncMessage(job, toolType) {
510
+ const waitCmd = `Utilisez task_wait avec l'ID ${job.id} pour attendre la fin et récupérer le résultat.`;
511
+ return `Tâche ${toolType} ${job.id} passée en arrière-plan (timeout dépassé). Statut actuel: ${job.status || 'pending'}.\n${waitCmd}`;
512
+ }
513
+
367
514
  // --- EXÉCUTION DE TÂCHES ---
368
515
  server.registerTool(
369
516
  "task_transfer",
370
517
  {
371
518
  title: "Transférer un fichier ou dossier (SFTP)",
372
- description: `Lance un transfert SFTP. Si la tâche prend moins de ${config.syncTimeout / 1000}s, le résultat est direct. Sinon, elle passe en arrière-plan.`,
373
- inputSchema: {
374
- alias: z.string().describe("Alias du serveur cible."),
375
- direction: z.enum(['upload', 'download']),
376
- local: z.string().describe("Chemin absolu local."),
377
- remote: z.string().describe("Chemin absolu distant."),
378
- rappel: z.number().optional().describe("Définit un rappel en secondes.")
379
- }
519
+ description: `Lance un transfert SFTP. Si la tâche prend moins de ${config.syncTimeout / 1000}s, le résultat est direct. Sinon, elle passe en arrière-plan. direction 'server_to_server' transfert entre deux serveurs distants (via source_alias → alias).`,
520
+ inputSchema: z.object({
521
+ alias: z.string().describe("Alias du serveur cible."),
522
+ direction: z.enum(['upload', 'download', 'server_to_server']),
523
+ local: z.string().optional().describe("Chemin absolu local (upload/download)."),
524
+ remote: z.string().describe("Chemin absolu distant."),
525
+ source_alias: z.string().optional().describe("Alias source (requis si direction='server_to_server')."),
526
+ force: z.boolean().optional().default(false).describe("Écraser les fichiers existants sans confirmation."),
527
+ rappel: z.number().optional().describe("Définit un rappel en secondes.")
528
+ })
380
529
  },
381
530
  async (params) => {
382
531
  const job = queue.addJob({ type: 'sftp', ...params, status: 'pending' });
@@ -385,10 +534,14 @@ server.registerTool(
385
534
 
386
535
  const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
387
536
  if (finalJob) {
388
- return { content: [{ type: "text", text: `Résultat direct (tâche ${finalJob.id}):
389
- ${JSON.stringify(finalJob, null, 2)}` }] };
537
+ return {
538
+ content: [{
539
+ type: "text", text: `Résultat direct (tâche ${finalJob.id}):
540
+ ${JSON.stringify(finalJob, null, 2)}`
541
+ }]
542
+ };
390
543
  } else {
391
- return { content: [{ type: "text", text: `Tâche de transfert ${job.id} initiée en arrière-plan.` }] };
544
+ return { content: [{ type: "text", text: buildAsyncMessage(job, 'de transfert') }] };
392
545
  }
393
546
  }
394
547
  );
@@ -396,26 +549,46 @@ ${JSON.stringify(finalJob, null, 2)}` }] };
396
549
  server.registerTool(
397
550
  "task_exec",
398
551
  {
399
- title: "Exécuter une commande à distance (SSH)",
400
- description: `Exécute une commande SSH. Si la tâche prend moins de ${config.syncTimeout / 1000}s, le résultat est direct. Sinon, elle passe en arrière-plan.`,
401
- inputSchema: {
402
- alias: z.string().describe("Alias du serveur cible."),
403
- cmd: z.string().describe("La commande complète à exécuter."),
404
- rappel: z.number().optional().describe("Définit un rappel en secondes.")
405
- }
552
+ title: "Exécuter une commande à distance (SSH) — multi-serveur supporté",
553
+ description: `Exécute une commande SSH. Si la tâche prend moins de ${config.syncTimeout / 1000}s, le résultat est direct. Sinon, elle passe en arrière-plan. Supporte alias unique ("vps1"), multiple (["vps1","vps2"]) ou "all" pour tout le parc.`,
554
+ inputSchema: z.object({
555
+ alias: z.union([z.string(), z.array(z.string())]).describe("Alias du serveur cible, tableau d'alias, ou 'all' pour tout le parc."),
556
+ cmd: z.string().describe("La commande complète à exécuter."),
557
+ timeout: z.number().optional().describe("Timeout en secondes. 0 = pas de limite. Défaut: 600s (10 min)."),
558
+ skip_policy: z.boolean().optional().default(false).describe("Ignorer la politique de sécurité (blocklist)."),
559
+ rappel: z.number().optional().describe("Définit un rappel en secondes.")
560
+ })
406
561
  },
407
562
  async (params) => {
408
- const job = queue.addJob({ type: 'ssh', ...params, status: 'pending' });
409
- history.logTask(job);
410
- ssh.executeCommand(job.id);
563
+ const aliases = Array.isArray(params.alias) ? params.alias
564
+ : params.alias === 'all' ? Object.keys(await servers.listServers())
565
+ : [params.alias];
411
566
 
412
- const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
413
- if (finalJob) {
414
- return { content: [{ type: "text", text: `Résultat direct (tâche ${finalJob.id}):
415
- ${finalJob.output || JSON.stringify(finalJob, null, 2)}` }] };
416
- } else {
417
- return { content: [{ type: "text", text: `Tâche d'exécution ${job.id} initiée en arrière-plan.` }] };
567
+ if (aliases.length === 1) {
568
+ const job = queue.addJob({ type: 'ssh', alias: aliases[0], cmd: params.cmd, timeout: params.timeout, skip_policy: params.skip_policy, status: 'pending' });
569
+ history.logTask(job);
570
+ ssh.executeCommand(job.id);
571
+ const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
572
+ if (finalJob) {
573
+ return { content: [{ type: "text", text: `Résultat direct (tâche ${finalJob.id}):\n${finalJob.output || JSON.stringify(finalJob, null, 2)}` }] };
574
+ } else {
575
+ return { content: [{ type: "text", text: buildAsyncMessage(job, "d'exécution") }] };
576
+ }
418
577
  }
578
+
579
+ const jobs = aliases.map(alias =>
580
+ queue.addJob({ type: 'ssh', alias, cmd: params.cmd, timeout: params.timeout, skip_policy: params.skip_policy, status: 'pending' })
581
+ );
582
+ jobs.forEach(j => { history.logTask(j); ssh.executeCommand(j.id); });
583
+
584
+ const results = await Promise.all(jobs.map(j => waitForJobCompletion(j.id, config.syncTimeout)));
585
+ const output = results.map((r, i) => {
586
+ if (!r) return `[${aliases[i]}] Timeout ou erreur`;
587
+ if (r.status === 'failed') return `[${aliases[i]}] ERREUR: ${r.error}`;
588
+ return `[${aliases[i]}] OK\n${r.output || '(vide)'}`;
589
+ }).join('\n\n---\n\n');
590
+
591
+ return { content: [{ type: "text", text: `Résultats multi-serveur (${aliases.length} cibles):\n\n${output}` }] };
419
592
  }
420
593
  );
421
594
 
@@ -434,7 +607,7 @@ server.registerTool(
434
607
  {
435
608
  title: "Voir la file d'attente des tâches",
436
609
  description: "Affiche le statut de toutes les tâches, avec des rappels pour les tâches longues.",
437
- inputSchema: {}
610
+ inputSchema: z.object({})
438
611
  },
439
612
  async () => {
440
613
  const queueState = queue.getQueue();
@@ -448,9 +621,9 @@ server.registerTool(
448
621
  {
449
622
  title: "Consulter une tâche par son ID",
450
623
  description: "Récupère les détails d'une seule tâche, avec un rappel si nécessaire.",
451
- inputSchema: {
624
+ inputSchema: z.object({
452
625
  id: z.string().describe("L'ID de la tâche à consulter.")
453
- }
626
+ })
454
627
  },
455
628
  async (params) => {
456
629
  const job = queue.getJob(params.id);
@@ -466,9 +639,9 @@ server.registerTool(
466
639
  {
467
640
  title: "Consulter l'historique des tâches",
468
641
  description: "Affiche les dernières tâches lancées. Peut être filtré par alias.",
469
- inputSchema: {
642
+ inputSchema: z.object({
470
643
  alias: z.string().optional().describe("Filtre l'historique pour ne montrer que les tâches d'un alias spécifique.")
471
- }
644
+ })
472
645
  },
473
646
  async (params) => {
474
647
  const historyLogs = await history.getHistory(params);
@@ -483,20 +656,21 @@ server.registerTool(
483
656
  {
484
657
  title: "Transférer plusieurs fichiers/dossiers (SFTP)",
485
658
  description: "Lance des transferts SFTP multiples avec support de patterns glob (*, ?, []).",
486
- inputSchema: {
659
+ inputSchema: z.object({
487
660
  alias: z.string().describe("Alias du serveur cible."),
488
661
  direction: z.enum(['upload', 'download']),
489
662
  files: z.array(z.object({
490
663
  local: z.string().describe("Chemin local ou pattern glob (ex: /home/*.txt)"),
491
664
  remote: z.string().describe("Chemin distant")
492
665
  })).describe("Liste des fichiers à transférer"),
666
+ force: z.boolean().optional().default(false).describe("Écraser les fichiers existants sans confirmation."),
493
667
  rappel: z.number().optional().describe("Définit un rappel en secondes.")
494
- }
668
+ })
495
669
  },
496
670
  async (params) => {
497
- const job = queue.addJob({
498
- type: 'sftp',
499
- ...params,
671
+ const job = queue.addJob({
672
+ type: 'sftp',
673
+ ...params,
500
674
  status: 'pending',
501
675
  files: params.files
502
676
  });
@@ -507,7 +681,7 @@ server.registerTool(
507
681
  if (finalJob) {
508
682
  return { content: [{ type: "text", text: `Résultat transferts multiples (tâche ${finalJob.id}):\n${JSON.stringify(finalJob, null, 2)}` }] };
509
683
  } else {
510
- return { content: [{ type: "text", text: `Tâche de transferts multiples ${job.id} initiée en arrière-plan.` }] };
684
+ return { content: [{ type: "text", text: buildAsyncMessage(job, 'de transferts multiples') }] };
511
685
  }
512
686
  }
513
687
  );
@@ -517,20 +691,20 @@ server.registerTool(
517
691
  {
518
692
  title: "Exécuter une commande interactive (SSH)",
519
693
  description: "Exécute une commande SSH avec gestion des prompts interactifs (yes/no, passwords, etc.).",
520
- inputSchema: {
694
+ inputSchema: z.object({
521
695
  alias: z.string().describe("Alias du serveur cible."),
522
696
  cmd: z.string().describe("La commande à exécuter."),
523
697
  interactive: z.boolean().optional().default(true).describe("Mode interactif."),
524
698
  autoRespond: z.boolean().optional().default(true).describe("Répondre automatiquement aux prompts standards."),
525
699
  responses: z.record(z.string()).optional().describe("Réponses personnalisées aux prompts (clé: pattern, valeur: réponse)."),
526
- timeout: z.number().optional().describe("Timeout personnalisé en secondes. Défaut 2 minutes."),
700
+ timeout: z.number().optional().describe("Timeout en secondes. 0 = pas de limite. Défaut: 300s (5 min)."),
527
701
  rappel: z.number().optional().describe("Définit un rappel en secondes.")
528
- }
702
+ })
529
703
  },
530
704
  async (params) => {
531
- const job = queue.addJob({
532
- type: 'ssh',
533
- ...params,
705
+ const job = queue.addJob({
706
+ type: 'ssh',
707
+ ...params,
534
708
  status: 'pending',
535
709
  interactive: params.interactive,
536
710
  autoRespond: params.autoRespond
@@ -542,7 +716,7 @@ server.registerTool(
542
716
  if (finalJob) {
543
717
  return { content: [{ type: "text", text: `Résultat commande interactive (tâche ${finalJob.id}):\n${finalJob.output || JSON.stringify(finalJob, null, 2)}` }] };
544
718
  } else {
545
- return { content: [{ type: "text", text: `Tâche interactive ${job.id} initiée en arrière-plan.` }] };
719
+ return { content: [{ type: "text", text: buildAsyncMessage(job, 'interactive') }] };
546
720
  }
547
721
  }
548
722
  );
@@ -552,7 +726,7 @@ server.registerTool(
552
726
  {
553
727
  title: "Exécuter une séquence de commandes (SSH)",
554
728
  description: "Exécute plusieurs commandes SSH en séquence sur le même serveur.",
555
- inputSchema: {
729
+ inputSchema: z.object({
556
730
  alias: z.string().describe("Alias du serveur cible."),
557
731
  commands: z.array(z.union([
558
732
  z.string(),
@@ -561,25 +735,27 @@ server.registerTool(
561
735
  timeout: z.number().optional(),
562
736
  continueOnError: z.boolean().optional()
563
737
  })
564
- ])).describe("Liste des commandes à exécuter en séquence."),
738
+ ])).min(1).describe("Liste des commandes à exécuter en séquence (minimum 1)."),
565
739
  continueOnError: z.boolean().optional().default(false).describe("Continuer même si une commande échoue."),
740
+ timeout: z.number().optional().describe("Timeout global en secondes. 0 = pas de limite. Défaut: 600s (10 min)."),
566
741
  rappel: z.number().optional().describe("Définit un rappel en secondes.")
567
- }
742
+ })
568
743
  },
569
744
  async (params) => {
570
- const job = queue.addJob({
571
- type: 'ssh_sequence',
572
- ...params,
745
+ const job = queue.addJob({
746
+ type: 'ssh_sequence',
747
+ ...params,
573
748
  status: 'pending'
574
749
  });
575
750
  history.logTask(job);
576
751
  ssh.executeCommandSequence(job.id);
577
752
 
578
- const finalJob = await waitForJobCompletion(job.id, config.syncTimeout * params.commands.length);
753
+ const seqTimeout = params.timeout === 0 ? 0 : (params.timeout ? params.timeout * 1000 : config.syncTimeout * params.commands.length);
754
+ const finalJob = await waitForJobCompletion(job.id, seqTimeout);
579
755
  if (finalJob) {
580
756
  return { content: [{ type: "text", text: `Résultat séquence (tâche ${finalJob.id}):\n${JSON.stringify(finalJob, null, 2)}` }] };
581
757
  } else {
582
- return { content: [{ type: "text", text: `Séquence de commandes ${job.id} initiée en arrière-plan.` }] };
758
+ return { content: [{ type: "text", text: buildAsyncMessage(job, 'de séquence') }] };
583
759
  }
584
760
  }
585
761
  );
@@ -589,7 +765,7 @@ server.registerTool(
589
765
  {
590
766
  title: "Statistiques du pool de connexions SSH",
591
767
  description: "Affiche les statistiques du pool de connexions SSH persistantes.",
592
- inputSchema: {}
768
+ inputSchema: z.object({})
593
769
  },
594
770
  async () => {
595
771
  const stats = ssh.getPoolStats();
@@ -602,7 +778,7 @@ server.registerTool(
602
778
  {
603
779
  title: "Statistiques de la queue",
604
780
  description: "Affiche les statistiques détaillées de la queue de tâches.",
605
- inputSchema: {}
781
+ inputSchema: z.object({})
606
782
  },
607
783
  async () => {
608
784
  const stats = queue.getStats();
@@ -621,14 +797,14 @@ server.registerTool(
621
797
  {
622
798
  title: "Réessayer une tâche échouée",
623
799
  description: "Relance une tâche qui a échoué ou crashé.",
624
- inputSchema: {
800
+ inputSchema: z.object({
625
801
  id: z.string().describe("L'ID de la tâche à réessayer.")
626
- }
802
+ })
627
803
  },
628
804
  async (params) => {
629
805
  try {
630
806
  const newJob = await queue.retryJob(params.id);
631
-
807
+
632
808
  // Relancer selon le type
633
809
  if (newJob.type === 'sftp') {
634
810
  sftp.executeTransfer(newJob.id);
@@ -637,7 +813,7 @@ server.registerTool(
637
813
  } else if (newJob.type === 'ssh_sequence') {
638
814
  ssh.executeCommandSequence(newJob.id);
639
815
  }
640
-
816
+
641
817
  return { content: [{ type: "text", text: `Tâche ${params.id} relancée avec le nouvel ID: ${newJob.id}` }] };
642
818
  } catch (e) {
643
819
  const errorPayload = {
@@ -655,11 +831,11 @@ server.registerTool(
655
831
  {
656
832
  title: "Consulter les logs système",
657
833
  description: "Affiche les logs du système MCP.",
658
- inputSchema: {
834
+ inputSchema: z.object({
659
835
  level: z.enum(['error', 'warn', 'info', 'debug']).optional().describe("Filtrer par niveau de log."),
660
836
  search: z.string().optional().describe("Rechercher dans les messages."),
661
837
  limit: z.number().optional().default(50).describe("Nombre de logs à afficher.")
662
- }
838
+ })
663
839
  },
664
840
  async (params) => {
665
841
  const logs = queue.getLogs({
@@ -677,12 +853,12 @@ server.registerTool(
677
853
  {
678
854
  title: "Récupérer les logs PM2",
679
855
  description: "Raccourci pour récupérer les logs PM2 d'une application spécifique ou de toutes les apps.",
680
- inputSchema: {
856
+ inputSchema: z.object({
681
857
  alias: z.string().describe("Alias du serveur cible."),
682
858
  app: z.string().optional().describe("Nom de l'application PM2 (optionnel, toutes par défaut)."),
683
859
  lines: z.number().optional().default(100).describe("Nombre de lignes à récupérer."),
684
860
  errors: z.boolean().optional().default(false).describe("Récupérer uniquement les erreurs (stderr).")
685
- }
861
+ })
686
862
  },
687
863
  async (params) => {
688
864
  let cmd = 'pm2 logs';
@@ -698,7 +874,7 @@ server.registerTool(
698
874
  if (finalJob) {
699
875
  return { content: [{ type: "text", text: `📋 Logs PM2${params.app ? ` (${params.app})` : ''} - ${finalJob.lineCount || 0} lignes:\n\n${finalJob.output || '(vide)'}` }] };
700
876
  } else {
701
- return { content: [{ type: "text", text: `Tâche ${job.id} initiée en arrière-plan.` }] };
877
+ return { content: [{ type: "text", text: buildAsyncMessage(job, 'PM2 logs') }] };
702
878
  }
703
879
  }
704
880
  );
@@ -707,20 +883,19 @@ server.registerTool(
707
883
  "get_docker_logs",
708
884
  {
709
885
  title: "Récupérer les logs Docker",
710
- description: "Raccourci pour récupérer les logs d'un container Docker.",
711
- inputSchema: {
886
+ inputSchema: z.object({
712
887
  alias: z.string().describe("Alias du serveur cible."),
713
888
  container: z.string().describe("Nom ou ID du container Docker."),
714
889
  lines: z.number().optional().default(100).describe("Nombre de lignes à récupérer."),
715
890
  since: z.string().optional().describe("Logs depuis (ex: '5m', '1h', '2024-01-01')."),
716
891
  timestamps: z.boolean().optional().default(false).describe("Afficher les timestamps.")
717
- }
892
+ })
718
893
  },
719
894
  async (params) => {
720
895
  let cmd = `docker logs --tail ${params.lines}`;
721
896
  if (params.since) cmd += ` --since ${params.since}`;
722
897
  if (params.timestamps) cmd += ' --timestamps';
723
- cmd += ` ${params.container}`;
898
+ cmd += ` ${utils.escapeShellArg(params.container)}`;
724
899
 
725
900
  const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd: cmd, streaming: false });
726
901
  history.logTask(job);
@@ -730,7 +905,7 @@ server.registerTool(
730
905
  if (finalJob) {
731
906
  return { content: [{ type: "text", text: `🐳 Logs Docker (${params.container}) - ${finalJob.lineCount || 0} lignes:\n\n${finalJob.output || '(vide)'}` }] };
732
907
  } else {
733
- return { content: [{ type: "text", text: `Tâche ${job.id} initiée en arrière-plan.` }] };
908
+ return { content: [{ type: "text", text: buildAsyncMessage(job, 'Docker logs') }] };
734
909
  }
735
910
  }
736
911
  );
@@ -740,14 +915,14 @@ server.registerTool(
740
915
  {
741
916
  title: "Afficher les dernières lignes d'un fichier",
742
917
  description: "Équivalent de tail -n pour afficher les dernières lignes d'un fichier distant.",
743
- inputSchema: {
918
+ inputSchema: z.object({
744
919
  alias: z.string().describe("Alias du serveur cible."),
745
920
  filepath: z.string().describe("Chemin absolu du fichier à lire."),
746
921
  lines: z.number().optional().default(50).describe("Nombre de lignes à afficher.")
747
- }
922
+ })
748
923
  },
749
924
  async (params) => {
750
- const cmd = `tail -n ${params.lines} ${params.filepath}`;
925
+ const cmd = `tail -n ${params.lines} ${utils.escapeShellArg(params.filepath)}`;
751
926
  const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd: cmd, streaming: false });
752
927
  history.logTask(job);
753
928
  ssh.executeCommand(job.id);
@@ -756,17 +931,1194 @@ server.registerTool(
756
931
  if (finalJob) {
757
932
  return { content: [{ type: "text", text: `📄 Contenu de ${params.filepath} (${finalJob.lineCount || 0} lignes):\n\n${finalJob.output || '(vide)'}` }] };
758
933
  } else {
759
- return { content: [{ type: "text", text: `Tâche ${job.id} initiée en arrière-plan.` }] };
934
+ return { content: [{ type: "text", text: buildAsyncMessage(job, 'tail') }] };
760
935
  }
761
936
  }
762
937
  );
763
938
 
764
- // --- DÉMARRAGE DU SERVEUR ---
765
- async function main() {
766
- const transport = new StdioServerTransport();
767
- await server.connect(transport);
939
+ // --- OUTIL D'AIDE / GUIDE ---
940
+
941
+ server.registerTool(
942
+ "help",
943
+ {
944
+ title: "Guide et documentation des outils",
945
+ description: "Affiche la documentation complète d'un outil ou la liste de tous les outils disponibles.",
946
+ inputSchema: z.object({
947
+ tool: z.string().optional().describe("Nom de l'outil à documenter. Laissez vide pour la liste complète.")
948
+ })
949
+ },
950
+ async (params) => {
951
+ // Schémas des paramètres pour les outils clés
952
+ const TOOL_SCHEMAS = {
953
+ policy_blocklist_list: `Paramètres: aucun`,
954
+
955
+ policy_blocklist_add: `Paramètres:
956
+ pattern (string, requis) — Pattern de commande à bloquer (ex: "rm -rf /" ou "wget * | sh")`,
957
+
958
+ policy_blocklist_remove: `Paramètres:
959
+ pattern (string, requis) — Pattern à retirer de la blocklist`,
960
+
961
+ tmux_create: `Paramètres:
962
+ alias (string, requis) — Serveur cible
963
+ name (string, opt) — Nom de session (auto-généré si omis)
964
+ start_cmd (string, opt) — Commande initiale à lancer`,
965
+
966
+ tmux_exec: `Paramètres:
967
+ alias (string, requis) — Serveur cible
968
+ session (string, requis) — Nom de la session tmux
969
+ cmd (string, requis) — Commande à exécuter`,
970
+
971
+ tmux_read: `Paramètres:
972
+ alias (string, requis) — Serveur cible
973
+ session (string, requis) — Nom de la session`,
974
+
975
+ tmux_list: `Paramètres:
976
+ alias (string, requis) — Serveur cible`,
977
+
978
+ tmux_kill: `Paramètres:
979
+ alias (string, requis) — Serveur cible
980
+ session (string, requis) — Nom de la session à tuer`,
981
+
982
+ server_add: `Paramètres:
983
+ alias (string, requis) — Nom unique du serveur (ex: vps_production)
984
+ host (string, requis) — IP ou nom d'hôte
985
+ user (string, requis) — Utilisateur SSH
986
+ keyPath (string, optionnel) — Chemin absolu vers clé privée SSH
987
+ password (string, optionnel) — Mot de passe SSH
988
+ → Fournir keyPath OU password (au moins un des deux).`,
989
+
990
+ api_add: `Paramètres:
991
+ alias (string, requis) — Nom unique
992
+ url (string, requis) — URL de base (ex: https://api.example.com)
993
+ health_check_endpoint (string, opt) — Path de health (défaut: /)
994
+ health_check_method (GET|POST, opt) — Méthode HTTP (défaut: GET)
995
+ auth_method (none|api_key|htpasswd|both, opt)
996
+ api_key (string, opt) — Clé API
997
+ auth_header_name (string, opt) — Header pour la clé (défaut: Authorization)
998
+ auth_scheme (string, opt) — Schéma d'auth (défaut: Bearer)
999
+ htpasswd_user (string, opt) — Utilisateur Basic Auth
1000
+ htpasswd_pass (string, opt) — Mot de passe Basic Auth
1001
+ notes (string, opt) — Notes`,
1002
+
1003
+ task_exec: `Paramètres:
1004
+ alias (string|string[], requis) — Alias, tableau d'alias, ou "all"
1005
+ cmd (string, requis) — Commande à exécuter
1006
+ timeout (number, opt) — Timeout en secondes (0=infini, défaut: 600)
1007
+ skip_policy (bool, opt) — Ignorer la blocklist (défaut: false)
1008
+ rappel (number, opt) — Rappel en secondes`,
1009
+
1010
+ file_read: `Paramètres:
1011
+ source (object, requis) — {type:'local'|'remote', path, alias?, label?}
1012
+ encoding (utf8|base64, opt) — Défaut: utf8
1013
+ offset (int, opt) — Ligne de départ (1-indexée)
1014
+ limit (int, opt) — Nombre de lignes`,
1015
+
1016
+ file_edit: `Paramètres:
1017
+ source (object, requis) — {type, path, alias?, label?}
1018
+ oldString (string, opt*) — Texte à remplacer (mode chirurgical)
1019
+ newString (string, opt*) — Texte de remplacement
1020
+ replaceAll (bool, opt) — Remplacer toutes les occurrences
1021
+ newContent (string, opt*) — Contenu complet (mode complet)
1022
+ expectedHash (string, opt) — Hash SHA-256 (anti-écrasement)
1023
+ dryRun (bool, opt) — Prévisualiser sans écrire
1024
+ backup (bool, opt) — Snapshot avant modif
1025
+ → (*) Soit oldString+newString, soit newContent.`,
1026
+
1027
+ file_write: `Paramètres:
1028
+ source (object, requis) — {type, path, alias?, label?}
1029
+ content (string, requis) — Contenu à écrire
1030
+ encoding (utf8|base64, opt) — Défaut: utf8
1031
+ createDirs (bool, opt) — Créer les dossiers parents
1032
+ dryRun (bool, opt) — Prévisualiser sans écrire
1033
+ backup (bool, opt) — Snapshot avant écrasement`,
1034
+
1035
+ task_transfer: `Paramètres:
1036
+ alias (string, requis) — Serveur cible
1037
+ direction (upload|download, requis)
1038
+ local (string, requis) — Chemin local absolu
1039
+ remote (string, requis) — Chemin distant absolu
1040
+ force (bool, opt) — Écraser sans confirmation
1041
+ rappel (number, opt) — Rappel en secondes`,
1042
+
1043
+ snapshot_create: `Paramètres:
1044
+ source (object, requis) — {type:'local'|'remote', alias?}
1045
+ paths (string[], req) — Chemins à capturer
1046
+ tag (string, opt) — Étiquette (ex: 'before-fix')
1047
+ message (string, opt) — Description
1048
+ recursive (bool, opt) — Parcours récursif (défaut: true)
1049
+ ignorePatterns (string[], opt) — Motifs glob à exclure`,
1050
+
1051
+ diff_files: `Paramètres:
1052
+ source1 (object, requis) — {type, path, alias?, label?}
1053
+ source2 (object, requis) — {type, path, alias?, label?}`,
1054
+
1055
+ compare_all_sources: `Paramètres:
1056
+ sources (object[], requis) — [{type, path, alias?, label?}, ...] (min 2)
1057
+ includeContent (bool, opt) — Inclure le contenu complet
1058
+ includeDiff (bool, opt) — Générer le diff (défaut: true)`,
1059
+
1060
+ tunnel_create: `Paramètres:
1061
+ name (string, requis) — Nom unique (ex: 'crm-dev')
1062
+ type (local|remote|socks) — Type de tunnel
1063
+ listen_port (number, requis) — Port d'écoute (ex: 8080)
1064
+ target (string, opt) — Cible host:port, requis pour local/remote
1065
+ via (string, requis) — Alias du serveur de transit SSH
1066
+ source (string, opt) — null=local, ou alias serveur`,
1067
+
1068
+ tunnel_list: `Paramètres: aucun`,
1069
+
1070
+ tunnel_close: `Paramètres:
1071
+ name (string, requis) — Nom du tunnel à fermer`,
1072
+
1073
+ tunnel_allowlist_add: `Paramètres:
1074
+ port (number, requis) — Port à autoriser`,
1075
+
1076
+ tunnel_allowlist_remove: `Paramètres:
1077
+ port (number, requis) — Port à retirer`
1078
+ };
1079
+
1080
+ const allTools = [
1081
+ { name: "help", desc: "Guide et documentation des outils (celui-ci)." },
1082
+ { name: "system_diagnostics", desc: "Diagnostic complet du système MCP (queue, pool, serveurs, APIs)." },
1083
+ { name: "server_add", desc: "Ajouter/modifier un alias de serveur SSH." },
1084
+ { name: "server_list", desc: "Lister tous les serveurs configurés." },
1085
+ { name: "server_remove", desc: "Supprimer un alias de serveur." },
1086
+ { name: "api_add", desc: "Ajouter une API au catalogue de monitoring." },
1087
+ { name: "api_list", desc: "Lister toutes les APIs du catalogue." },
1088
+ { name: "api_remove", desc: "Supprimer une API du catalogue." },
1089
+ { name: "api_check", desc: "Test de santé d'une API via son alias (SSH curl)." },
1090
+ { name: "get_system_resources", desc: "Métriques CPU, RAM, Disque d'un serveur." },
1091
+ { name: "get_services_status", desc: "Statut des services systemd, Docker, PM2." },
1092
+ { name: "check_api_health", desc: "Test HTTP direct sur une URL." },
1093
+ { name: "get_fail2ban_status", desc: "Statut Fail2Ban (toutes les jails ou une spécifique)." },
1094
+ { name: "task_exec", desc: "Exécuter une commande SSH. Timeout paramétrable (0=infini)." },
1095
+ { name: "task_transfer", desc: "Transfert SFTP fichier/dossier. server_to_server: transfert direct entre deux serveurs distants." },
1096
+ { name: "task_exec_interactive", desc: "SSH avec prompts interactifs (yes/no, menus). Supporte regex dans responses." },
1097
+ { name: "task_exec_sequence", desc: "Séquence de commandes SSH sur le même serveur." },
1098
+ { name: "task_transfer_multi", desc: "Transferts SFTP multiples avec patterns glob." },
1099
+ { name: "task_queue", desc: "Voir toutes les tâches en cours/en attente." },
1100
+ { name: "task_status", desc: "Détail d'une tâche par son ID." },
1101
+ { name: "task_history", desc: "Historique des tâches, filtrable par alias." },
1102
+ { name: "task_retry", desc: "Relancer une tâche échouée ou crashée." },
1103
+ { name: "task_wait", desc: "Attendre la fin d'une tâche en arrière-plan (jusqu'à 600s)." },
1104
+ { name: "task_logs", desc: "Logs internes du système MCP." },
1105
+ { name: "queue_stats", desc: "Statistiques de la file d'attente." },
1106
+ { name: "pool_stats", desc: "Statistiques du pool de connexions SSH." },
1107
+ { name: "get_pm2_logs", desc: "Récupérer les logs PM2 d'une app." },
1108
+ { name: "get_docker_logs", desc: "Récupérer les logs d'un container Docker." },
1109
+ { name: "tail_file", desc: "Afficher les dernières lignes d'un fichier distant." },
1110
+ { name: "file_read", desc: "Lire un fichier (local ou remote) + hash SHA-256. source:{type,path,alias?}." },
1111
+ { name: "file_write", desc: "Créer/écraser un fichier (local ou remote). dryRun (preview) + backup (snapshot avant écrasement)." },
1112
+ { name: "file_edit", desc: "Éditer chirurgical (oldString/newString) ou complet (newContent). dryRun + backup + protection hash." },
1113
+ { name: "diff_files", desc: "Comparer 2 fichiers (local/remote, cross-server). Diff unifié + origine des sources." },
1114
+ { name: "diff_folders", desc: "Comparer 2 dossiers : fichiers ajoutés/supprimés/modifiés. ignorePatterns supporté." },
1115
+ { name: "compare_all_sources", desc: "Comparer un fichier sur N sources (parc). Regroupe par hash, détecte les drifts." },
1116
+ { name: "shell_create", desc: "Ouvrir une session shell persistante (cd/export persistent) sur un serveur distant." },
1117
+ { name: "shell_exec", desc: "Exécuter une commande dans une session shell persistante (état conservé)." },
1118
+ { name: "shell_list", desc: "Lister les sessions shell actives (alias, âge, commandes, état)." },
1119
+ { name: "shell_close", desc: "Fermer proprement une session shell persistante." },
1120
+ { name: "snapshot_create", desc: "Capturer l'état de fichiers/dossiers (local/remote) avec déduplication." },
1121
+ { name: "snapshot_list", desc: "Lister les snapshots d'infrastructure (filtrable par source/tag)." },
1122
+ { name: "snapshot_diff", desc: "Comparer 2 snapshots (même cross local↔remote). Détection de drift." },
1123
+ { name: "snapshot_restore", desc: "Restaurer un snapshot (dryRun par défaut, force requis pour écraser)." },
1124
+ { name: "snapshot_delete", desc: "Supprimer un snapshot + nettoyer les blobs orphelins." },
1125
+ { name: "guide", desc: "Manuel IA : workflows, cheatsheet, pièges (section:index/workflows/...)." },
1126
+ { name: "server_note_set", desc: "Définir le contexte d'un serveur (description, services, warnings, conventions)." },
1127
+ { name: "server_note_get", desc: "Lire le contexte/protocole d'un serveur (à consulter avant d'intervenir)." },
1128
+ { name: "server_note_list", desc: "Vue d'ensemble des notes de tout le parc." },
1129
+ { name: "server_note_remove", desc: "Supprimer la note d'un serveur." },
1130
+ { name: "infra_overview", desc: "Vue synthétique du parc : serveurs + notes (rôle, services, warnings)." },
1131
+ { name: "policy_blocklist_list", desc: "Lister les commandes bloquées par la politique de sécurité." },
1132
+ { name: "policy_blocklist_add", desc: "Ajouter un pattern à la blocklist." },
1133
+ { name: "policy_blocklist_remove", desc: "Retirer un pattern de la blocklist." },
1134
+ { name: "tmux_create", desc: "Créer une session tmux persistante sur un serveur." },
1135
+ { name: "tmux_exec", desc: "Envoyer une commande dans une session tmux." },
1136
+ { name: "tmux_read", desc: "Lire le buffer d'une session tmux." },
1137
+ { name: "tmux_list", desc: "Lister les sessions tmux actives." },
1138
+ { name: "tmux_kill", desc: "Tuer une session tmux." },
1139
+ { name: "tunnel_create", desc: "Créer un tunnel SSH (local/remote/socks)." },
1140
+ { name: "tunnel_list", desc: "Lister les tunnels actifs." },
1141
+ { name: "tunnel_close", desc: "Fermer un tunnel." },
1142
+ { name: "tunnel_allowlist_add", desc: "Ajouter un port à l'allowlist." },
1143
+ { name: "tunnel_allowlist_remove", desc: "Retirer un port de l'allowlist." }
1144
+ ];
1145
+
1146
+ const envVars = [
1147
+ { var: "MCP_DATA_DIR", defaut: "~/.config/mcp-orchestrator", desc: "Dossier des fichiers de données (JSON)." },
1148
+ { var: "MCP_SYNC_TIMEOUT_S", defaut: "120", desc: "Délai en secondes avant passage en arrière-plan." },
1149
+ { var: "MCP_DEFAULT_CMD_TIMEOUT_S", defaut: "600", desc: "Timeout SSH par défaut (0=infini)." },
1150
+ { var: "MCP_INTERACTIVE_CMD_TIMEOUT_S", defaut: "300", desc: "Timeout mode interactif (0=infini)." },
1151
+ { var: "MCP_MAX_WAIT_TIMEOUT_S", defaut: "600", desc: "Timeout maximum pour task_wait." },
1152
+ { var: "MAX_CONNECTIONS_PER_SERVER", defaut: "5", desc: "Connexions SSH max par serveur." },
1153
+ { var: "MIN_CONNECTIONS_PER_SERVER", defaut: "1", desc: "Connexions SSH min par serveur." },
1154
+ { var: "IDLE_TIMEOUT", defaut: "300000", desc: "Délai avant fermeture connexion inactive (ms)." },
1155
+ { var: "KEEP_ALIVE_INTERVAL", defaut: "30000", desc: "Intervalle keepalive SSH (ms)." },
1156
+ { var: "MAX_QUEUE_SIZE", defaut: "1000", desc: "Nombre max de jobs dans la queue." },
1157
+ { var: "SAVE_INTERVAL", defaut: "5000", desc: "Intervalle de sauvegarde de la queue (ms)." },
1158
+ { var: "MCP_DEBUG", defaut: "false", desc: "Activer les logs de debug détaillés." },
1159
+ { var: "MCP_TRANSPORT", defaut: "stdio", desc: "Transport: stdio, http, ou both (v9+)." },
1160
+ { var: "MCP_HTTP_PORT", defaut: "3457", desc: "Port du serveur HTTP (si transport=http ou both)." }
1161
+ ];
1162
+
1163
+ const usageTips = [
1164
+ "Pour les longues opérations: utilisez timeout:0 sur task_exec, puis task_wait pour récupérer le résultat.",
1165
+ "Transferts SFTP: si un fichier existe déjà, utilisez force:true pour l'écraser (sinon refusé).",
1166
+ "Mode interactif: passez un objet responses avec des patterns→réponses pour les prompts attendus.",
1167
+ "Les patterns dans responses supportent les regex. Ex: '[YyNn]\\\\?' → 'y'.",
1168
+ "Utilisez task_queue pour voir les jobs en cours, task_status <id> pour un job précis.",
1169
+ "Les jobs 'crashed' (rouges) sont réessayables avec task_retry."
1170
+ ];
1171
+
1172
+ if (params.tool) {
1173
+ const tool = allTools.find(t => t.name === params.tool);
1174
+ if (!tool) {
1175
+ const errorPayload = { toolName: "help", errorCode: "UNKNOWN_TOOL", errorMessage: `Outil '${params.tool}' inconnu. Utilisez help sans paramètre pour la liste.` };
1176
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1177
+ }
1178
+ const schema = TOOL_SCHEMAS[params.tool];
1179
+ let detail = `Outil: ${tool.name}\nDescription: ${tool.desc}`;
1180
+ if (schema) detail += `\n\n${schema}`;
1181
+ detail += `\n\nAstuce: guide section:audit pour l'audit complet du parc.`;
1182
+ return { content: [{ type: "text", text: detail }] };
1183
+ }
1184
+
1185
+ const guide = `=== GUIDE MCP ORCHESTRATOR v11.3.0 ===
1186
+
1187
+ OUTILS DISPONIBLES (${allTools.length}):
1188
+ ${allTools.map(t => ` ${t.name}: ${t.desc}`).join('\n')}
1189
+
1190
+ VARIABLES D'ENVIRONNEMENT (.env):
1191
+ ${envVars.map(v => ` ${v.var} (défaut: ${v.defaut}) — ${v.desc}`).join('\n')}
1192
+
1193
+ ASTUCES D'UTILISATION:
1194
+ ${usageTips.map(t => ` • ${t}`).join('\n')}
1195
+
1196
+ Pour la doc complète: help tool:<nom> ou consultez le README.`;
1197
+
1198
+ return { content: [{ type: "text", text: guide }] };
1199
+ }
1200
+ );
1201
+
1202
+ // --- ATTENTE EXPLICITE ---
1203
+
1204
+ server.registerTool(
1205
+ "task_wait",
1206
+ {
1207
+ title: "Attendre la fin d'une tâche en arrière-plan",
1208
+ description: `Prend un ID de job et attend jusqu'à ${config.maxWaitTimeout / 1000}s que la tâche se termine, puis retourne le résultat.`,
1209
+ inputSchema: z.object({
1210
+ id: z.string().describe("L'ID de la tâche à attendre.")
1211
+ })
1212
+ },
1213
+ async (params) => {
1214
+ const existingJob = queue.getJob(params.id);
1215
+ if (!existingJob) {
1216
+ const errorPayload = {
1217
+ toolName: "task_wait",
1218
+ errorCode: "JOB_NOT_FOUND",
1219
+ errorMessage: `Tâche '${params.id}' introuvable.`
1220
+ };
1221
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1222
+ }
1223
+
1224
+ if (existingJob.status === 'completed' || existingJob.status === 'failed') {
1225
+ return { content: [{ type: "text", text: `Tâche ${params.id} déjà terminée:\n${JSON.stringify(existingJob, null, 2)}` }] };
1226
+ }
1227
+
1228
+ const finalJob = await waitForJobCompletion(params.id, config.maxWaitTimeout);
1229
+ if (finalJob) {
1230
+ return { content: [{ type: "text", text: `Résultat tâche ${finalJob.id}:\n${JSON.stringify(finalJob, null, 2)}` }] };
1231
+ } else {
1232
+ return { content: [{ type: "text", text: `Tâche ${params.id} toujours en cours (peut-être terminée entre-temps). Réessayez task_wait.` }] };
1233
+ }
1234
+ }
1235
+ );
1236
+
1237
+ // --- OPÉRATIONS FICHIERS (LOCAL + REMOTE) ---
1238
+
1239
+ // Résout la config serveur si remote, et valide la cohérence type/alias
1240
+ function resolveSource(source) {
1241
+ if (source.type === 'remote' && !source.alias) {
1242
+ throw new Error("Source remote invalide : 'alias' est requis quand type='remote'.");
1243
+ }
1244
+ return source; // sourceAdapter récupère la config via serverManager.getServer(alias)
768
1245
  }
769
1246
 
1247
+ server.registerTool(
1248
+ "file_read",
1249
+ {
1250
+ title: "Lire un fichier (local ou distant)",
1251
+ description: "Lit un fichier et retourne son contenu + hash SHA-256 (pour édition sécurisée). Supporte localhost (sans SSH) et remote (SFTP). encoding 'base64' pour les binaires. offset/limit (par lignes, utf8) pour consulter un gros fichier sans tout charger — le hash reste celui du fichier complet (edit-safe).",
1252
+ inputSchema: z.object({
1253
+ source: sourceSchema,
1254
+ encoding: z.enum(['utf8', 'base64']).optional().default('utf8'),
1255
+ offset: z.number().int().positive().optional().describe("Ligne de départ (1-indexée) pour lecture partielle"),
1256
+ limit: z.number().int().positive().optional().describe("Nombre de lignes à retourner (lecture partielle)")
1257
+ })
1258
+ },
1259
+ async (params) => {
1260
+ try {
1261
+ const source = resolveSource(params.source);
1262
+ const result = await fileOps.readFile(source, params.encoding, {
1263
+ offset: params.offset,
1264
+ limit: params.limit
1265
+ });
1266
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1267
+ } catch (e) {
1268
+ const errorPayload = { toolName: "file_read", errorCode: "READ_ERROR", errorMessage: e.message };
1269
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1270
+ }
1271
+ }
1272
+ );
1273
+
1274
+ server.registerTool(
1275
+ "file_write",
1276
+ {
1277
+ title: "Écrire un fichier (local ou distant)",
1278
+ description: "Crée ou écrase un fichier. Crée les dossiers parents si besoin. Supporte localhost (sans SSH) et remote (SFTP). dryRun:true pour prévisualiser le diff sans écrire. backup:true pour snapshotter l'ancien fichier avant écrasement (undo via snapshot_restore). Pour modifier un fichier existant, préférez file_edit (édition chirurgicale).",
1279
+ inputSchema: z.object({
1280
+ source: sourceSchema,
1281
+ content: z.string().describe("Contenu à écrire"),
1282
+ encoding: z.enum(['utf8', 'base64']).optional().default('utf8'),
1283
+ createDirs: z.boolean().optional().default(true),
1284
+ dryRun: z.boolean().optional().default(false).describe("Prévisualiser le diff sans écrire"),
1285
+ backup: z.boolean().optional().default(false).describe("Snapshot du fichier existant avant écrasement (filet de sécurité)")
1286
+ })
1287
+ },
1288
+ async (params) => {
1289
+ try {
1290
+ const source = resolveSource(params.source);
1291
+ const result = await fileOps.writeFile(source, params.content, params.encoding, {
1292
+ createDirs: params.createDirs,
1293
+ dryRun: params.dryRun,
1294
+ backup: params.backup
1295
+ });
1296
+ // Affichage client : si un diff est présent (dryRun), le rendre en markdown lisible
1297
+ if (result.diff && result.diff !== '(binaire — diff non affiché)') {
1298
+ const title = result.dryRun ? `Prévisualisation écriture — ${result.origin.label}` : result.origin.label;
1299
+ const fmt = diffFormatter.format(result.diff, { title });
1300
+ return diffFormatter.response(fmt.markdown, result);
1301
+ }
1302
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1303
+ } catch (e) {
1304
+ const errorPayload = { toolName: "file_write", errorCode: "WRITE_ERROR", errorMessage: e.message };
1305
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1306
+ }
1307
+ }
1308
+ );
1309
+
1310
+ server.registerTool(
1311
+ "file_edit",
1312
+ {
1313
+ title: "Éditer un fichier (local ou distant) — chirurgical ou complet",
1314
+ description: "Édite un fichier. DEUX MODES : (A) chirurgical via oldString/newString (remplace un bout précis, économe en tokens) ; (B) complet via newContent (remplace tout). Protection expectedHash optionnelle (anti-écrasement concurrent). dryRun:true = prévisualiser le diff sans écrire. backup:true = snapshot avant modif (undo via snapshot_restore). Workflow recommandé: file_read → file_edit avec oldString/newString + expectedHash.",
1315
+ inputSchema: z.object({
1316
+ source: sourceSchema,
1317
+ oldString: z.string().optional().describe("Mode chirurgical : texte exact à remplacer (avec indentation/espaces)"),
1318
+ newString: z.string().optional().describe("Mode chirurgical : texte de remplacement"),
1319
+ replaceAll: z.boolean().optional().default(false).describe("Mode chirurgical : remplacer toutes les occurrences (sinon erreur si multiples)"),
1320
+ newContent: z.string().optional().describe("Mode complet : nouveau contenu intégral du fichier"),
1321
+ expectedHash: z.string().optional().describe("Hash SHA-256 obtenu via file_read (protection anti-écrasement, recommandé)"),
1322
+ encoding: z.enum(['utf8', 'base64']).optional().default('utf8'),
1323
+ dryRun: z.boolean().optional().default(false).describe("Prévisualiser le diff sans écrire"),
1324
+ backup: z.boolean().optional().default(false).describe("Snapshot du fichier avant modification (filet de sécurité)")
1325
+ })
1326
+ },
1327
+ async (params) => {
1328
+ try {
1329
+ const source = resolveSource(params.source);
1330
+ const result = await fileOps.editFile(source, {
1331
+ oldString: params.oldString,
1332
+ newString: params.newString,
1333
+ replaceAll: params.replaceAll,
1334
+ newContent: params.newContent,
1335
+ expectedHash: params.expectedHash,
1336
+ encoding: params.encoding,
1337
+ dryRun: params.dryRun,
1338
+ backup: params.backup
1339
+ });
1340
+ // Affichage client : diff rendu en markdown lisible (```diff coloré)
1341
+ if (result.diff) {
1342
+ const title = result.dryRun
1343
+ ? `Prévisualisation édition — ${result.origin.label}`
1344
+ : `Édition appliquée — ${result.origin.label}`;
1345
+ const fmt = diffFormatter.format(result.diff, { title });
1346
+ return diffFormatter.response(fmt.markdown, result);
1347
+ }
1348
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1349
+ } catch (e) {
1350
+ if (e.code === 'HASH_MISMATCH') {
1351
+ const errorPayload = {
1352
+ toolName: "file_edit",
1353
+ errorCode: "HASH_MISMATCH",
1354
+ errorMessage: e.message,
1355
+ currentHash: e.current?.hash,
1356
+ currentContent: e.current?.content
1357
+ };
1358
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1359
+ }
1360
+ if (e.code === 'OLDSTRING_NOT_FOUND' || e.code === 'MULTIPLE_MATCHES') {
1361
+ const errorPayload = {
1362
+ toolName: "file_edit",
1363
+ errorCode: e.code,
1364
+ errorMessage: e.message,
1365
+ ...(e.occurrences ? { occurrences: e.occurrences } : {})
1366
+ };
1367
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1368
+ }
1369
+ const errorPayload = { toolName: "file_edit", errorCode: "EDIT_ERROR", errorMessage: e.message };
1370
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1371
+ }
1372
+ }
1373
+ );
1374
+
1375
+ // --- COMPARAISON / DIFF (LOCAL + REMOTE, CROSS-SERVER) ---
1376
+
1377
+ server.registerTool(
1378
+ "diff_files",
1379
+ {
1380
+ title: "Comparer deux fichiers (local/remote, cross-server)",
1381
+ description: "Génère un diff unifié entre deux fichiers. N'importe quelle combinaison : local↔local, local↔remote, remote↔remote. Indique l'origine (serveur+chemin) de chaque source. Retourne identical:true si contenus identiques.",
1382
+ inputSchema: z.object({
1383
+ source1: sourceSchema,
1384
+ source2: sourceSchema
1385
+ })
1386
+ },
1387
+ async (params) => {
1388
+ try {
1389
+ const s1 = resolveSource(params.source1);
1390
+ const s2 = resolveSource(params.source2);
1391
+ const result = await diffEngine.diffFiles(s1, s2);
1392
+ // Affichage client : diff en markdown si les fichiers diffèrent
1393
+ if (!result.identical && result.diff) {
1394
+ const title = `${result.source1.label} → ${result.source2.label}`;
1395
+ const fmt = diffFormatter.format(result.diff, { title });
1396
+ return diffFormatter.response(fmt.markdown, result);
1397
+ }
1398
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1399
+ } catch (e) {
1400
+ const errorPayload = { toolName: "diff_files", errorCode: "DIFF_ERROR", errorMessage: e.message };
1401
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1402
+ }
1403
+ }
1404
+ );
1405
+
1406
+ server.registerTool(
1407
+ "diff_folders",
1408
+ {
1409
+ title: "Comparer deux dossiers (local/remote, cross-server)",
1410
+ description: "Compare les arborescences de deux dossiers. Retourne les fichiers présents uniquement d'un côté, identiques, et modifiés (avec compteur +/- lignes). includeDiff:true pour le diff détaillé de chaque fichier modifié. ignorePatterns pour exclure (ex: ['node_modules','*.log']).",
1411
+ inputSchema: z.object({
1412
+ source1: sourceSchema,
1413
+ source2: sourceSchema,
1414
+ recursive: z.boolean().optional().default(true),
1415
+ compareContent: z.boolean().optional().default(true),
1416
+ includeDiff: z.boolean().optional().default(false).describe("Inclure le diff unifié complet pour chaque fichier modifié"),
1417
+ ignorePatterns: z.array(z.string()).optional().describe("Motifs glob à ignorer (ex: ['node_modules', '*.log', '.git'])")
1418
+ })
1419
+ },
1420
+ async (params) => {
1421
+ try {
1422
+ const s1 = resolveSource(params.source1);
1423
+ const s2 = resolveSource(params.source2);
1424
+ const result = await diffEngine.diffFolders(s1, s2, {
1425
+ recursive: params.recursive,
1426
+ compareContent: params.compareContent,
1427
+ includeDiff: params.includeDiff,
1428
+ ignorePatterns: params.ignorePatterns
1429
+ });
1430
+ // Affichage client : résumé lisible des fichiers (modifiés/ajoutés/supprimés)
1431
+ const hasChanges = result.modified.length || result.only_in_source1.length || result.only_in_source2.length;
1432
+ if (hasChanges) {
1433
+ const md = diffFormatter.formatFileList({
1434
+ modified: result.modified,
1435
+ added: result.only_in_source2, // présents côté source2 uniquement
1436
+ removed: result.only_in_source1 // présents côté source1 uniquement
1437
+ }, { title: `${result.source1.label} → ${result.source2.label}` });
1438
+ return { content: [{ type: "text", text: md }, { type: "text", text: JSON.stringify(result, null, 2) }] };
1439
+ }
1440
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1441
+ } catch (e) {
1442
+ const errorPayload = { toolName: "diff_folders", errorCode: "DIFF_ERROR", errorMessage: e.message };
1443
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1444
+ }
1445
+ }
1446
+ );
1447
+
1448
+ server.registerTool(
1449
+ "compare_all_sources",
1450
+ {
1451
+ title: "Comparer un fichier sur plusieurs sources (détection drift)",
1452
+ description: "Compare un fichier sur plusieurs sources (localhost + serveurs distants). Regroupe par hash : plus d'un groupe = drift. Chaque source peut avoir un chemin DIFFÉRENT (ex: .bashrc sur un VPS, .zshrc sur un autre) et un 'label' personnalisé pour un affichage clair. Le groupe majoritaire sert de référence ; includeDiff (défaut true) génère le diff réel référence→divergences.",
1453
+ inputSchema: z.object({
1454
+ sources: z.array(sourceSchema).min(2).describe("Liste de sources à comparer (2+, local et/ou remote). Chaque source peut avoir path différent + label."),
1455
+ includeContent: z.boolean().optional().default(false).describe("Inclure le contenu complet de chaque version distincte"),
1456
+ includeDiff: z.boolean().optional().default(true).describe("Générer le diff réel entre la référence (groupe majoritaire) et chaque version divergente")
1457
+ })
1458
+ },
1459
+ async (params) => {
1460
+ try {
1461
+ const sources = params.sources.map(resolveSource);
1462
+ const result = await compareEngine.compareSources(sources, {
1463
+ includeContent: params.includeContent,
1464
+ includeDiff: params.includeDiff
1465
+ });
1466
+ // Affichage client : si drift, rendre le(s) diff(s) référence→divergence en markdown
1467
+ if (result.drift && result.drifts && result.drifts.length > 0) {
1468
+ const parts = [`### ⚠️ Drift détecté — ${result.stats.uniqueVersions} versions sur ${result.stats.totalSources} sources`];
1469
+ parts.push(`Référence : \`${result.reference.label}\` (${result.reference.sources.length} source(s))\n`);
1470
+ for (const d of result.drifts) {
1471
+ if (d.diff) {
1472
+ const fmt = diffFormatter.format(d.diff, { title: `${d.from} → ${d.to}` });
1473
+ parts.push(fmt.markdown);
1474
+ }
1475
+ }
1476
+ return { content: [{ type: "text", text: parts.join('\n\n') }, { type: "text", text: JSON.stringify(result, null, 2) }] };
1477
+ }
1478
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1479
+ } catch (e) {
1480
+ const errorPayload = { toolName: "compare_all_sources", errorCode: "COMPARE_ERROR", errorMessage: e.message };
1481
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1482
+ }
1483
+ }
1484
+ );
1485
+
1486
+ // --- SESSIONS SHELL PERSISTANTES (remote) ---
1487
+
1488
+ server.registerTool(
1489
+ "shell_create",
1490
+ {
1491
+ title: "Créer une session shell persistante",
1492
+ description: "Ouvre un shell PTY persistant sur un serveur distant où cd, export, activations d'env PERSISTENT entre les commandes (contrairement à task_exec qui isole chaque commande). Retourne un sessionId à utiliser avec shell_exec. Fermer avec shell_close quand terminé.",
1493
+ inputSchema: z.object({
1494
+ alias: z.string().describe("Alias du serveur cible"),
1495
+ workdir: z.string().optional().describe("Répertoire de démarrage (cd initial)"),
1496
+ env: z.record(z.string()).optional().describe("Variables d'environnement initiales {CLE: valeur}"),
1497
+ cmdTimeout: z.number().optional().describe("Timeout par défaut des commandes en secondes (défaut 300, 0=infini)")
1498
+ })
1499
+ },
1500
+ async (params) => {
1501
+ try {
1502
+ const result = await shellSessions.createSession(params.alias, {
1503
+ workdir: params.workdir,
1504
+ env: params.env,
1505
+ cmdTimeout: params.cmdTimeout
1506
+ });
1507
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1508
+ } catch (e) {
1509
+ const errorPayload = { toolName: "shell_create", errorCode: "SHELL_CREATE_ERROR", errorMessage: e.message };
1510
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1511
+ }
1512
+ }
1513
+ );
1514
+
1515
+ server.registerTool(
1516
+ "shell_exec",
1517
+ {
1518
+ title: "Exécuter dans une session shell persistante",
1519
+ description: "Exécute une commande dans une session shell existante (créée via shell_create). L'état (répertoire courant, variables) PERSISTE entre les appels. Retourne { output, exitCode, timedOut }. Une seule commande à la fois par session.",
1520
+ inputSchema: z.object({
1521
+ sessionId: z.string().describe("ID de session obtenu via shell_create"),
1522
+ command: z.string().describe("Commande à exécuter"),
1523
+ timeout: z.number().optional().describe("Timeout en secondes pour cette commande (0=infini)")
1524
+ })
1525
+ },
1526
+ async (params) => {
1527
+ try {
1528
+ const result = await shellSessions.execInSession(params.sessionId, params.command, params.timeout);
1529
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1530
+ } catch (e) {
1531
+ const errorPayload = { toolName: "shell_exec", errorCode: "SHELL_EXEC_ERROR", errorMessage: e.message };
1532
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1533
+ }
1534
+ }
1535
+ );
1536
+
1537
+ server.registerTool(
1538
+ "shell_list",
1539
+ {
1540
+ title: "Lister les sessions shell actives",
1541
+ description: "Affiche toutes les sessions shell persistantes en cours avec leur état (alias, âge, inactivité, nombre de commandes, occupée/fermée).",
1542
+ inputSchema: z.object({})
1543
+ },
1544
+ async () => {
1545
+ try {
1546
+ const sessions = shellSessions.listSessions();
1547
+ return { content: [{ type: "text", text: JSON.stringify({ sessions, count: sessions.length }, null, 2) }] };
1548
+ } catch (e) {
1549
+ const errorPayload = { toolName: "shell_list", errorCode: "SHELL_LIST_ERROR", errorMessage: e.message };
1550
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1551
+ }
1552
+ }
1553
+ );
1554
+
1555
+ server.registerTool(
1556
+ "shell_close",
1557
+ {
1558
+ title: "Fermer une session shell persistante",
1559
+ description: "Termine proprement une session shell (ferme le shell et la connexion SSH dédiée). À faire quand la session n'est plus nécessaire. Les sessions inactives sont aussi fermées automatiquement après 30 min.",
1560
+ inputSchema: z.object({
1561
+ sessionId: z.string().describe("ID de session à fermer")
1562
+ })
1563
+ },
1564
+ async (params) => {
1565
+ try {
1566
+ const result = shellSessions.closeSession(params.sessionId);
1567
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1568
+ } catch (e) {
1569
+ const errorPayload = { toolName: "shell_close", errorCode: "SHELL_CLOSE_ERROR", errorMessage: e.message };
1570
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1571
+ }
1572
+ }
1573
+ );
1574
+
1575
+ // --- SNAPSHOTS D'INFRASTRUCTURE (local + remote, versioning + dedup) ---
1576
+
1577
+ server.registerTool(
1578
+ "snapshot_create",
1579
+ {
1580
+ title: "Créer un snapshot d'infrastructure",
1581
+ description: "Capture l'état de fichiers/dossiers critiques (localhost sans SSH, ou serveur distant via SFTP). Déduplication automatique par contenu (hash). Idéal avant une modification risquée. Retourne un snapshotId.",
1582
+ inputSchema: z.object({
1583
+ source: z.object({
1584
+ type: z.enum(['local', 'remote']),
1585
+ alias: z.string().optional().describe("Requis si type='remote'")
1586
+ }),
1587
+ paths: z.array(z.string()).min(1).describe("Chemins absolus à capturer (fichiers ou dossiers)"),
1588
+ tag: z.string().optional().describe("Étiquette (ex: 'before-nginx-fix')"),
1589
+ message: z.string().optional(),
1590
+ recursive: z.boolean().optional().default(true),
1591
+ ignorePatterns: z.array(z.string()).optional().describe("Motifs glob à exclure (ex: ['node_modules','*.log'])")
1592
+ })
1593
+ },
1594
+ async (params) => {
1595
+ try {
1596
+ if (params.source.type === 'remote' && !params.source.alias) {
1597
+ throw new Error("alias requis quand type='remote'.");
1598
+ }
1599
+ const result = await snapshotManager.createSnapshot(params.source, params.paths, {
1600
+ tag: params.tag,
1601
+ message: params.message,
1602
+ recursive: params.recursive,
1603
+ ignorePatterns: params.ignorePatterns
1604
+ });
1605
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1606
+ } catch (e) {
1607
+ const errorPayload = { toolName: "snapshot_create", errorCode: "SNAPSHOT_ERROR", errorMessage: e.message };
1608
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1609
+ }
1610
+ }
1611
+ );
1612
+
1613
+ server.registerTool(
1614
+ "snapshot_list",
1615
+ {
1616
+ title: "Lister les snapshots d'infrastructure",
1617
+ description: "Affiche les snapshots filtrés par source (localhost, serveur, tag). Résumé léger (id, serveur, tag, date, nb fichiers).",
1618
+ inputSchema: z.object({
1619
+ sourceType: z.enum(['local', 'remote']).optional(),
1620
+ sourceAlias: z.string().optional(),
1621
+ tag: z.string().optional(),
1622
+ limit: z.number().optional().default(20)
1623
+ })
1624
+ },
1625
+ async (params) => {
1626
+ try {
1627
+ const result = await snapshotManager.listSnapshots({
1628
+ sourceType: params.sourceType,
1629
+ sourceAlias: params.sourceAlias,
1630
+ tag: params.tag,
1631
+ limit: params.limit
1632
+ });
1633
+ return { content: [{ type: "text", text: JSON.stringify({ snapshots: result, count: result.length }, null, 2) }] };
1634
+ } catch (e) {
1635
+ const errorPayload = { toolName: "snapshot_list", errorCode: "LIST_ERROR", errorMessage: e.message };
1636
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1637
+ }
1638
+ }
1639
+ );
1640
+
1641
+ server.registerTool(
1642
+ "snapshot_diff",
1643
+ {
1644
+ title: "Comparer deux snapshots",
1645
+ description: "Diff entre deux snapshots (par chemin). Fonctionne même entre un snapshot local et un snapshot remote (détection de drift local↔prod). includeDiff:true pour le détail ligne à ligne.",
1646
+ inputSchema: z.object({
1647
+ snapshot1: z.string().describe("ID ou tag du premier snapshot"),
1648
+ snapshot2: z.string().describe("ID ou tag du second snapshot"),
1649
+ includeDiff: z.boolean().optional().default(false)
1650
+ })
1651
+ },
1652
+ async (params) => {
1653
+ try {
1654
+ const result = await snapshotManager.diffSnapshots(params.snapshot1, params.snapshot2, {
1655
+ includeDiff: params.includeDiff
1656
+ });
1657
+ // Affichage client : résumé lisible des fichiers changés entre snapshots
1658
+ const hasChanges = result.modified.length || result.added.length || result.removed.length;
1659
+ if (hasChanges) {
1660
+ let md = diffFormatter.formatFileList({
1661
+ modified: result.modified,
1662
+ added: result.added,
1663
+ removed: result.removed
1664
+ }, { title: `${result.snapshot1.id} → ${result.snapshot2.id}` });
1665
+ // Si includeDiff, ajoute les diffs détaillés des fichiers modifiés
1666
+ if (params.includeDiff) {
1667
+ for (const m of result.modified) {
1668
+ if (m.diff) md += '\n\n' + diffFormatter.format(m.diff, { title: m.path }).markdown;
1669
+ }
1670
+ }
1671
+ return { content: [{ type: "text", text: md }, { type: "text", text: JSON.stringify(result, null, 2) }] };
1672
+ }
1673
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1674
+ } catch (e) {
1675
+ const errorPayload = { toolName: "snapshot_diff", errorCode: "DIFF_ERROR", errorMessage: e.message };
1676
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1677
+ }
1678
+ }
1679
+ );
1680
+
1681
+ server.registerTool(
1682
+ "snapshot_restore",
1683
+ {
1684
+ title: "Restaurer un snapshot",
1685
+ description: "Restaure les fichiers d'un snapshot vers une cible (local ou remote). DANGEREUX : dryRun:true par défaut (prévisualisation). Restauration réelle exige dryRun:false ET force:true. Peut restaurer un snapshot remote vers local (backup) et inversement (déploiement).",
1686
+ inputSchema: z.object({
1687
+ snapshotId: z.string().describe("ID ou tag du snapshot"),
1688
+ target: z.object({
1689
+ type: z.enum(['local', 'remote']),
1690
+ alias: z.string().optional().describe("Requis si type='remote'")
1691
+ }),
1692
+ paths: z.array(z.string()).optional().describe("Restaurer uniquement ces chemins (préfixes)"),
1693
+ dryRun: z.boolean().optional().default(true),
1694
+ force: z.boolean().optional().default(false)
1695
+ })
1696
+ },
1697
+ async (params) => {
1698
+ try {
1699
+ if (params.target.type === 'remote' && !params.target.alias) {
1700
+ throw new Error("alias requis quand type='remote'.");
1701
+ }
1702
+ // Sécurité : restauration réelle bloquée sans force explicite
1703
+ if (!params.dryRun && !params.force) {
1704
+ const errorPayload = {
1705
+ toolName: "snapshot_restore",
1706
+ errorCode: "CONFIRMATION_REQUIRED",
1707
+ errorMessage: "Restauration réelle bloquée. Faites d'abord un dryRun:true pour prévisualiser, puis dryRun:false + force:true pour confirmer.",
1708
+ hint: "Toujours valider avec l'utilisateur avant force:true (écrasement de fichiers)."
1709
+ };
1710
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1711
+ }
1712
+ const result = await snapshotManager.restoreSnapshot(params.snapshotId, params.target, {
1713
+ paths: params.paths,
1714
+ dryRun: params.dryRun,
1715
+ force: params.force
1716
+ });
1717
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1718
+ } catch (e) {
1719
+ const errorPayload = { toolName: "snapshot_restore", errorCode: "RESTORE_ERROR", errorMessage: e.message };
1720
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1721
+ }
1722
+ }
1723
+ );
1724
+
1725
+ server.registerTool(
1726
+ "snapshot_delete",
1727
+ {
1728
+ title: "Supprimer un snapshot",
1729
+ description: "Supprime un snapshot et nettoie les blobs devenus orphelins (dedup-aware : ne supprime que les contenus plus référencés). Retourne l'espace libéré.",
1730
+ inputSchema: z.object({
1731
+ snapshotId: z.string().describe("ID ou tag du snapshot à supprimer")
1732
+ })
1733
+ },
1734
+ async (params) => {
1735
+ try {
1736
+ const result = await snapshotManager.deleteSnapshot(params.snapshotId);
1737
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1738
+ } catch (e) {
1739
+ const errorPayload = { toolName: "snapshot_delete", errorCode: "DELETE_ERROR", errorMessage: e.message };
1740
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1741
+ }
1742
+ }
1743
+ );
1744
+
1745
+ // --- NOTES / PROTOCOLE PAR SERVEUR (mémoire contextuelle du parc) ---
1746
+
1747
+ server.registerTool(
1748
+ "server_note_set",
1749
+ {
1750
+ title: "Définir la note/protocole d'un serveur",
1751
+ description: "Attache un contexte à un serveur pour ne pas se perdre dans le parc : description, services critiques, avertissements, conventions, note libre. Merge partiel (seuls les champs fournis sont modifiés). 'intervention' met à jour automatiquement last_intervention.",
1752
+ inputSchema: z.object({
1753
+ alias: z.string().describe("Alias du serveur"),
1754
+ description: z.string().optional().describe("Rôle du serveur"),
1755
+ services: z.array(z.string()).optional().describe("Services critiques (ex: ['nginx','pm2:proxy-gw'])"),
1756
+ warnings: z.array(z.string()).optional().describe("Avertissements (ex: ['RAM 1Go','ne pas rebuild en prod'])"),
1757
+ conventions: z.array(z.string()).optional().describe("Conventions/chemins (ex: ['configs dans /etc/nginx'])"),
1758
+ note: z.string().optional().describe("Note libre"),
1759
+ intervention: z.string().optional().describe("Résumé de la dernière intervention (horodatée automatiquement)")
1760
+ })
1761
+ },
1762
+ async (params) => {
1763
+ try {
1764
+ const { alias, ...fields } = params;
1765
+ const result = await notes.set(alias, fields);
1766
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1767
+ } catch (e) {
1768
+ const errorPayload = { toolName: "server_note_set", errorCode: "NOTE_ERROR", errorMessage: e.message };
1769
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1770
+ }
1771
+ }
1772
+ );
1773
+
1774
+ server.registerTool(
1775
+ "server_note_get",
1776
+ {
1777
+ title: "Lire la note/protocole d'un serveur",
1778
+ description: "Récupère le contexte enregistré d'un serveur (description, services, warnings, conventions, dernière intervention). À consulter avant d'intervenir sur un serveur.",
1779
+ inputSchema: z.object({
1780
+ alias: z.string().describe("Alias du serveur")
1781
+ })
1782
+ },
1783
+ async (params) => {
1784
+ try {
1785
+ const note = await notes.get(params.alias);
1786
+ if (!note) {
1787
+ return { content: [{ type: "text", text: JSON.stringify({ alias: params.alias, note: null, hint: "Aucune note. Utilisez server_note_set pour en créer une." }, null, 2) }] };
1788
+ }
1789
+ return { content: [{ type: "text", text: JSON.stringify({ alias: params.alias, ...note }, null, 2) }] };
1790
+ } catch (e) {
1791
+ const errorPayload = { toolName: "server_note_get", errorCode: "NOTE_ERROR", errorMessage: e.message };
1792
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1793
+ }
1794
+ }
1795
+ );
1796
+
1797
+ server.registerTool(
1798
+ "server_note_list",
1799
+ {
1800
+ title: "Lister toutes les notes de serveurs",
1801
+ description: "Vue d'ensemble des notes/protocoles de tout le parc.",
1802
+ inputSchema: z.object({})
1803
+ },
1804
+ async () => {
1805
+ try {
1806
+ const all = await notes.list();
1807
+ return { content: [{ type: "text", text: JSON.stringify({ notes: all, count: Object.keys(all).length }, null, 2) }] };
1808
+ } catch (e) {
1809
+ const errorPayload = { toolName: "server_note_list", errorCode: "NOTE_ERROR", errorMessage: e.message };
1810
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1811
+ }
1812
+ }
1813
+ );
1814
+
1815
+ server.registerTool(
1816
+ "server_note_remove",
1817
+ {
1818
+ title: "Supprimer la note d'un serveur",
1819
+ description: "Supprime le contexte enregistré d'un serveur.",
1820
+ inputSchema: z.object({
1821
+ alias: z.string().describe("Alias du serveur")
1822
+ })
1823
+ },
1824
+ async (params) => {
1825
+ try {
1826
+ const result = await notes.remove(params.alias);
1827
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1828
+ } catch (e) {
1829
+ const errorPayload = { toolName: "server_note_remove", errorCode: "NOTE_ERROR", errorMessage: e.message };
1830
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1831
+ }
1832
+ }
1833
+ );
1834
+
1835
+ // --- VUE SYNTHÉTIQUE DU PARC ---
1836
+
1837
+ server.registerTool(
1838
+ "infra_overview",
1839
+ {
1840
+ title: "Vue synthétique du parc",
1841
+ description: "Vue d'ensemble en un appel : liste des serveurs configurés + leurs notes (rôle, services, avertissements). Idéal en début de session pour situer le contexte sans se perdre.",
1842
+ inputSchema: z.object({})
1843
+ },
1844
+ async () => {
1845
+ try {
1846
+ const serverList = await servers.listServers();
1847
+ const allNotes = await notes.list();
1848
+ const overview = Object.entries(serverList).map(([alias, cfg]) => {
1849
+ const n = allNotes[alias] || {};
1850
+ return {
1851
+ alias,
1852
+ host: cfg.host,
1853
+ user: cfg.user,
1854
+ description: n.description || null,
1855
+ services: n.services || [],
1856
+ warnings: n.warnings || [],
1857
+ lastIntervention: n.last_intervention || null
1858
+ };
1859
+ });
1860
+ return { content: [{ type: "text", text: JSON.stringify({ servers: overview, count: overview.length }, null, 2) }] };
1861
+ } catch (e) {
1862
+ const errorPayload = { toolName: "infra_overview", errorCode: "OVERVIEW_ERROR", errorMessage: e.message };
1863
+ return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
1864
+ }
1865
+ }
1866
+ );
1867
+
1868
+ // --- OUTILS TMUX (sessions terminal persistantes) ---
1869
+
1870
+ // Helper: vérifie si tmux est installé sur le serveur, retourne un message si absent
1871
+ async function checkTmux(alias) {
1872
+ const job = queue.addJob({ type: 'ssh', alias, cmd: "command -v tmux 2>/dev/null", streaming: false, status: 'pending' });
1873
+ ssh.executeCommand(job.id);
1874
+ const result = await waitForJobCompletion(job.id, config.syncTimeout);
1875
+ if (!result || result.status === 'failed' || !result.output || result.output.trim() === '') {
1876
+ return { installed: false, message: `❌ tmux n'est pas installé sur '${alias}'. Pour l'installer: ssh ${alias} "sudo apt install -y tmux"` };
1877
+ }
1878
+ return { installed: true };
1879
+ }
1880
+
1881
+ server.registerTool(
1882
+ "tmux_create",
1883
+ {
1884
+ title: "Créer une session tmux",
1885
+ description: "Crée une nouvelle session tmux sur un serveur distant. La session survit aux déconnexions et aux redémarrages du MCP.",
1886
+ inputSchema: z.object({
1887
+ alias: z.string().describe("Alias du serveur cible."),
1888
+ name: z.string().optional().describe("Nom de la session tmux (auto-généré si omis)."),
1889
+ start_cmd: z.string().optional().describe("Commande initiale à lancer dans la session.")
1890
+ })
1891
+ },
1892
+ async (params) => {
1893
+ const tmuxCheck = await checkTmux(params.alias);
1894
+ if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
1895
+ const sessionName = params.name || `tmux-${Date.now()}`;
1896
+ const startCmd = params.start_cmd ? `tmux send-keys -t ${sessionName} '${params.start_cmd}' Enter` : '';
1897
+ const cmd = `tmux new-session -d -s ${sessionName} ${startCmd.length > 0 ? '&& ' + startCmd : ''}`;
1898
+ const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
1899
+ history.logTask(job);
1900
+ ssh.executeCommand(job.id);
1901
+ const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
1902
+ const output = finalJob ? finalJob.output || '' : 'Timeout';
1903
+ return { content: [{ type: "text", text: output.includes('error') ? `Erreur: ${output}` : `Session tmux '${sessionName}' créée sur ${params.alias}.` }] };
1904
+ }
1905
+ );
1906
+
1907
+ server.registerTool(
1908
+ "tmux_exec",
1909
+ {
1910
+ title: "Exécuter une commande dans une session tmux",
1911
+ description: "Envoie une commande à une session tmux existante.",
1912
+ inputSchema: z.object({
1913
+ alias: z.string().describe("Alias du serveur."),
1914
+ session: z.string().describe("Nom de la session tmux."),
1915
+ cmd: z.string().describe("Commande à exécuter dans la session.")
1916
+ })
1917
+ },
1918
+ async (params) => {
1919
+ const tmuxCheck = await checkTmux(params.alias);
1920
+ if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
1921
+ const cmd = `tmux send-keys -t ${params.session} '${params.cmd}' Enter`;
1922
+ const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
1923
+ history.logTask(job);
1924
+ ssh.executeCommand(job.id);
1925
+ const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
1926
+ return { content: [{ type: "text", text: finalJob ? `Commande envoyée à la session '${params.session}'.` : buildAsyncMessage(job, "tmux exec") }] };
1927
+ }
1928
+ );
1929
+
1930
+ server.registerTool(
1931
+ "tmux_read",
1932
+ {
1933
+ title: "Lire le buffer d'une session tmux",
1934
+ description: "Récupère le contenu affiché dans une session tmux.",
1935
+ inputSchema: z.object({
1936
+ alias: z.string().describe("Alias du serveur."),
1937
+ session: z.string().describe("Nom de la session tmux.")
1938
+ })
1939
+ },
1940
+ async (params) => {
1941
+ const tmuxCheck = await checkTmux(params.alias);
1942
+ if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
1943
+ const cmd = `tmux capture-pane -t ${params.session} -p -S -200`;
1944
+ const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
1945
+ history.logTask(job);
1946
+ ssh.executeCommand(job.id);
1947
+ const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
1948
+ const output = finalJob ? (finalJob.output || '(vide)') : 'Timeout';
1949
+ return { content: [{ type: "text", text: `Buffer de la session '${params.session}':\n\n${output}` }] };
1950
+ }
1951
+ );
1952
+
1953
+ server.registerTool(
1954
+ "tmux_list",
1955
+ {
1956
+ title: "Lister les sessions tmux",
1957
+ description: "Liste toutes les sessions tmux actives sur un serveur.",
1958
+ inputSchema: z.object({
1959
+ alias: z.string().describe("Alias du serveur.")
1960
+ })
1961
+ },
1962
+ async (params) => {
1963
+ const tmuxCheck = await checkTmux(params.alias);
1964
+ if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
1965
+ const cmd = "tmux list-sessions 2>/dev/null || echo '(aucune session tmux)'";
1966
+ const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
1967
+ history.logTask(job);
1968
+ ssh.executeCommand(job.id);
1969
+ const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
1970
+ const output = finalJob ? (finalJob.output || '(vide)') : 'Timeout';
1971
+ return { content: [{ type: "text", text: `Sessions tmux sur ${params.alias}:\n${output}` }] };
1972
+ }
1973
+ );
1974
+
1975
+ server.registerTool(
1976
+ "tmux_kill",
1977
+ {
1978
+ title: "Tuer une session tmux",
1979
+ description: "Supprime une session tmux.",
1980
+ inputSchema: z.object({
1981
+ alias: z.string().describe("Alias du serveur."),
1982
+ session: z.string().describe("Nom de la session à tuer.")
1983
+ })
1984
+ },
1985
+ async (params) => {
1986
+ const tmuxCheck = await checkTmux(params.alias);
1987
+ if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
1988
+ const cmd = `tmux kill-session -t ${params.session} 2>/dev/null && echo 'Session ${params.session} tuée.' || echo 'Session introuvable.'`;
1989
+ const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
1990
+ history.logTask(job);
1991
+ ssh.executeCommand(job.id);
1992
+ const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
1993
+ const output = finalJob ? (finalJob.output || 'OK') : 'Timeout';
1994
+ return { content: [{ type: "text", text: output }] };
1995
+ }
1996
+ );
1997
+
1998
+ // --- OUTILS TUNNELS SSH ---
1999
+
2000
+ server.registerTool(
2001
+ "tunnel_create",
2002
+ {
2003
+ title: "Créer un tunnel SSH",
2004
+ description: "Crée un tunnel SSH (local/remote/SOCKS5). source=null → local, source=alias → tmux sur le serveur distant. Le tunnel persiste via tmux si source est un serveur.",
2005
+ inputSchema: z.object({
2006
+ name: z.string().describe("Nom unique du tunnel (ex: 'crm-dev')."),
2007
+ type: z.enum(['local', 'remote', 'socks']).describe("local=-L, remote=-R, socks=-D"),
2008
+ listen_port: z.number().int().positive().describe("Port d'écoute (ex: 8080)."),
2009
+ target: z.string().optional().describe("Cible host:port (ex: '127.0.0.1:3100'). Non requis pour SOCKS."),
2010
+ via: z.string().describe("Serveur de transit SSH (alias)."),
2011
+ source: z.string().optional().describe("Où le tunnel tourne. null=local, ou alias serveur (ex: 'fkomprodmini2')."),
2012
+ key_path: z.string().optional().describe("Chemin de la clé SSH sur le serveur source (requis si source est distant).")
2013
+ })
2014
+ },
2015
+ async (params) => {
2016
+ try {
2017
+ const result = await tunnels.create(params, servers);
2018
+ return { content: [{ type: "text", text: result }] };
2019
+ } catch (e) {
2020
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "tunnel_create", errorCode: "TUNNEL_ERROR", errorMessage: e.message }, null, 2) }], isError: true };
2021
+ }
2022
+ }
2023
+ );
2024
+
2025
+ server.registerTool(
2026
+ "tunnel_list",
2027
+ {
2028
+ title: "Lister les tunnels",
2029
+ description: "Liste tous les tunnels SSH actifs et leur état.",
2030
+ inputSchema: z.object({})
2031
+ },
2032
+ async () => {
2033
+ const result = await tunnels.list();
2034
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2035
+ }
2036
+ );
2037
+
2038
+ server.registerTool(
2039
+ "tunnel_close",
2040
+ {
2041
+ title: "Fermer un tunnel",
2042
+ description: "Ferme un tunnel SSH par son nom.",
2043
+ inputSchema: z.object({
2044
+ name: z.string().describe("Nom du tunnel à fermer.")
2045
+ })
2046
+ },
2047
+ async (params) => {
2048
+ try {
2049
+ const result = await tunnels.close(params.name, servers);
2050
+ return { content: [{ type: "text", text: result }] };
2051
+ } catch (e) {
2052
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "tunnel_close", errorCode: "TUNNEL_ERROR", errorMessage: e.message }, null, 2) }], isError: true };
2053
+ }
2054
+ }
2055
+ );
2056
+
2057
+ server.registerTool(
2058
+ "tunnel_allowlist_add",
2059
+ {
2060
+ title: "Ajouter un port à l'allowlist des tunnels",
2061
+ description: "Autorise un port pour les tunnels SSH (sécurité).",
2062
+ inputSchema: z.object({
2063
+ port: z.number().int().positive().describe("Port à autoriser (ex: 3306).")
2064
+ })
2065
+ },
2066
+ async (params) => {
2067
+ const list = await tunnels.allowlistAdd(params.port);
2068
+ return { content: [{ type: "text", text: `Port ${params.port} ajouté à l'allowlist.\nPorts autorisés: ${list.join(', ')}` }] };
2069
+ }
2070
+ );
2071
+
2072
+ server.registerTool(
2073
+ "tunnel_allowlist_remove",
2074
+ {
2075
+ title: "Retirer un port de l'allowlist des tunnels",
2076
+ description: "Supprime un port de la liste des ports autorisés.",
2077
+ inputSchema: z.object({
2078
+ port: z.number().int().positive().describe("Port à retirer.")
2079
+ })
2080
+ },
2081
+ async (params) => {
2082
+ const list = await tunnels.allowlistRemove(params.port);
2083
+ return { content: [{ type: "text", text: `Port ${params.port} retiré de l'allowlist.\nPorts autorisés: ${list.join(', ')}` }] };
2084
+ }
2085
+ );
2086
+
2087
+ // --- GUIDE IA (manuel intégré) ---
2088
+
2089
+ server.registerTool(
2090
+ "guide",
2091
+ {
2092
+ description: "Manuel pour l'IA pilote : workflows copier-coller, cheatsheet, pièges à éviter. Complémentaire de 'help' (qui liste les outils). Appelle guide (section:'index') en début de session pour charger les bonnes pratiques.",
2093
+ inputSchema: z.object({
2094
+ section: z.enum(['index', 'sources', 'file-editing', 'workflows', 'cheatsheet', 'audit', 'security', 'pitfalls']).optional().default('index').describe("Section du guide")
2095
+ })
2096
+ },
2097
+ async (params) => {
2098
+ return { content: [{ type: "text", text: guide.get(params.section) }] };
2099
+ }
2100
+ );
2101
+
2102
+ // --- DÉMARRAGE DU SERVEUR ---
2103
+ async function main() {
2104
+ if (DEBUG) console.error("🔌 Connexion du transport stdio...");
2105
+ const transport = new StdioServerTransport();
2106
+ await server.connect(transport);
2107
+ if (DEBUG) console.error("🚀 Serveur stdio connecté et prêt !");
2108
+ }
2109
+
2110
+ async function shutdown() {
2111
+ if (DEBUG) console.error("Arrêt du serveur...");
2112
+ tunnels.shutdown();
2113
+ await queue.shutdown();
2114
+ sshPool.closeAll();
2115
+ shellSessions.closeAll();
2116
+ process.exit(0);
2117
+ }
2118
+
2119
+ process.on('SIGINT', shutdown);
2120
+ process.on('SIGTERM', shutdown);
2121
+
770
2122
  main().catch((error) => {
771
2123
  console.error("Erreur fatale du serveur:", error);
772
2124
  process.exit(1);