@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/.env.example +21 -15
- package/.gencodedoc.yaml +76 -0
- package/README.fr.md +369 -0
- package/README.md +333 -61
- package/ROADMAP.md +21 -0
- package/apis.js +17 -0
- package/compareEngine.js +132 -0
- package/config.js +35 -4
- package/diagnose.js +89 -0
- package/diffEngine.js +141 -0
- package/diffFormatter.js +114 -0
- package/fileOps.js +275 -0
- package/guide.js +236 -0
- package/history.js +8 -1
- package/notes.js +87 -0
- package/package.json +5 -22
- package/policies.js +76 -0
- package/queue.js +107 -121
- package/server.js +1460 -108
- package/sftp.js +131 -41
- package/shellSessions.js +237 -0
- package/snapshotManager.js +324 -0
- package/sourceAdapter.js +357 -0
- package/ssh.js +168 -84
- package/sshPool.js +52 -56
- package/test_mcp.js +55 -0
- package/tunnels.js +224 -0
- package/utils.js +6 -0
- package/publish1github.md +0 -216
package/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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 {
|
|
389
|
-
|
|
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:
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
409
|
-
|
|
410
|
-
|
|
563
|
+
const aliases = Array.isArray(params.alias) ? params.alias
|
|
564
|
+
: params.alias === 'all' ? Object.keys(await servers.listServers())
|
|
565
|
+
: [params.alias];
|
|
411
566
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
934
|
+
return { content: [{ type: "text", text: buildAsyncMessage(job, 'tail') }] };
|
|
760
935
|
}
|
|
761
936
|
}
|
|
762
937
|
);
|
|
763
938
|
|
|
764
|
-
// ---
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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);
|