@fkom13/mcp-sftp-orchestrator 11.3.0 → 11.8.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 +6 -0
- package/CHANGELOG.md +174 -0
- package/README.fr.md +230 -243
- package/README.md +268 -286
- package/apis.js +20 -81
- package/atomicJsonStore.js +117 -0
- package/config.js +5 -0
- package/fleet.js +170 -0
- package/groups.js +25 -0
- package/history.js +5 -51
- package/infraTopology.js +336 -0
- package/inventory.js +151 -0
- package/lhm.plugin.json +7 -0
- package/notes.js +3 -87
- package/package.json +8 -6
- package/policies.js +6 -74
- package/projects.js +108 -0
- package/queue.js +83 -14
- package/server.js +821 -90
- package/servers.js +30 -33
- package/sftp.js +168 -60
- package/shellSessions.js +31 -3
- package/snapshotManager.js +3 -7
- package/sourceAdapter.js +54 -1
- package/ssh.js +17 -3
- package/sshPool.js +129 -112
- package/sshTrust.js +214 -0
- package/toolMetadata.js +76 -0
- package/tunnels.js +36 -17
- package/utils.js +210 -1
- package/workSession.js +139 -0
- package/.gencodedoc.yaml +0 -76
- package/ROADMAP.md +0 -21
- package/test_features.js +0 -112
- package/test_mcp.js +0 -55
package/server.js
CHANGED
|
@@ -22,7 +22,15 @@ import notes from './notes.js';
|
|
|
22
22
|
import guide from './guide.js';
|
|
23
23
|
import policies from './policies.js';
|
|
24
24
|
import tunnels from './tunnels.js';
|
|
25
|
+
import fleet from './fleet.js';
|
|
26
|
+
import groups from './groups.js';
|
|
27
|
+
import projects from './projects.js';
|
|
28
|
+
import workSession from './workSession.js';
|
|
29
|
+
import inventory from './inventory.js';
|
|
30
|
+
import sshTrust from './sshTrust.js';
|
|
25
31
|
import { sourceSchema } from './sourceAdapter.js';
|
|
32
|
+
import infraTopology from './infraTopology.js';
|
|
33
|
+
import { enhanceToolConfig } from './toolMetadata.js';
|
|
26
34
|
|
|
27
35
|
if (DEBUG) {
|
|
28
36
|
console.error("✅ Tous les modules importés");
|
|
@@ -33,28 +41,89 @@ await queue.init();
|
|
|
33
41
|
|
|
34
42
|
if (DEBUG) console.error("✅ Queue initialisée");
|
|
35
43
|
|
|
44
|
+
if (config.readOnly) {
|
|
45
|
+
console.error("[INFO] MCP_READONLY actif — écritures / exec mutantes refusées.");
|
|
46
|
+
}
|
|
47
|
+
if (config.compact) {
|
|
48
|
+
console.error("[INFO] MCP_COMPACT actif — réponses tronquées.");
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
// Vérification des secrets en clair
|
|
37
52
|
try {
|
|
38
53
|
const serverList = await servers.listServers();
|
|
39
|
-
for (const [alias,
|
|
40
|
-
if (
|
|
54
|
+
for (const [alias, sc] of Object.entries(serverList)) {
|
|
55
|
+
if (sc.password) {
|
|
41
56
|
console.error(`[SECURITY WARN] Mot de passe en clair détecté pour le serveur '${alias}'. Utilisez une clé SSH ou Vaultwarden.`);
|
|
42
57
|
}
|
|
43
58
|
}
|
|
44
59
|
const apiList = await apis.listApis();
|
|
45
|
-
for (const [alias,
|
|
46
|
-
if (
|
|
60
|
+
for (const [alias, ac] of Object.entries(apiList)) {
|
|
61
|
+
if (ac.api_key || ac.htpasswd_pass) {
|
|
47
62
|
console.error(`[SECURITY WARN] Secret en clair détecté pour l'API '${alias}'. Utilisez Vaultwarden.`);
|
|
48
63
|
}
|
|
49
64
|
}
|
|
50
65
|
} catch (e) { /* silencieux, pas bloquant */ }
|
|
51
66
|
|
|
67
|
+
const ORCH_VERSION = config.version || '11.8.0';
|
|
68
|
+
|
|
52
69
|
const server = new McpServer({
|
|
53
70
|
name: "orchestrator",
|
|
54
|
-
version:
|
|
55
|
-
description: "
|
|
71
|
+
version: ORCH_VERSION,
|
|
72
|
+
description: "IACA Resource / Execution Orchestrator : machines, SSH, fichiers, jobs, transferts, terminaux, snapshots et topologie d'infrastructure."
|
|
56
73
|
});
|
|
57
74
|
|
|
75
|
+
// Enrichit les 82 tools avec les hints MCP standard et des descriptions de
|
|
76
|
+
// sélection sans dupliquer cette logique dans chaque registerTool. Les hints
|
|
77
|
+
// n'accordent aucun droit : les guards/policies serveur restent l'autorité.
|
|
78
|
+
const nativeRegisterTool = server.registerTool.bind(server);
|
|
79
|
+
server.registerTool = (name, toolConfig, handler) =>
|
|
80
|
+
nativeRegisterTool(name, enhanceToolConfig(name, toolConfig), handler);
|
|
81
|
+
|
|
82
|
+
/** Refuse si MCP_READONLY global. */
|
|
83
|
+
function guardWritable(actionLabel) {
|
|
84
|
+
if (config.readOnly) {
|
|
85
|
+
utils.assertWritable(actionLabel);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Refuse si alias marqué readonly:true dans servers.json (ou global). */
|
|
90
|
+
async function guardAliasWritable(alias, actionLabel) {
|
|
91
|
+
guardWritable(actionLabel);
|
|
92
|
+
if (alias && await servers.isAliasReadOnly(alias)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Alias '${alias}' est en lecture seule (servers.json readonly:true). ` +
|
|
95
|
+
`${actionLabel} refusé.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Applique la politique d'écriture aux transferts selon leur direction. */
|
|
101
|
+
async function guardTransferWritable(params, actionLabel) {
|
|
102
|
+
guardWritable(actionLabel);
|
|
103
|
+
// upload et server_to_server écrivent sur l'alias cible.
|
|
104
|
+
if (params.direction === 'upload' || params.direction === 'server_to_server') {
|
|
105
|
+
await guardAliasWritable(params.alias, actionLabel);
|
|
106
|
+
}
|
|
107
|
+
// download ne modifie pas le serveur distant, seulement le filesystem local ;
|
|
108
|
+
// le guard global ci-dessus suffit.
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Empêche task_retry de contourner readonly:true d'un alias. */
|
|
112
|
+
async function guardRetryJobWritable(job, actionLabel) {
|
|
113
|
+
guardWritable(actionLabel);
|
|
114
|
+
if (!job) throw new Error('Job introuvable.');
|
|
115
|
+
if (job.type === 'ssh' || job.type === 'ssh_sequence') {
|
|
116
|
+
await guardAliasWritable(job.alias, actionLabel);
|
|
117
|
+
} else if (job.type === 'sftp' && (job.direction === 'upload' || job.direction === 'server_to_server')) {
|
|
118
|
+
await guardAliasWritable(job.alias, actionLabel);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function jsonResult(obj, params = {}) {
|
|
123
|
+
const payload = utils.wantsCompact(params) ? utils.compactResult(obj) : obj;
|
|
124
|
+
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
125
|
+
}
|
|
126
|
+
|
|
58
127
|
if (DEBUG) console.error("✅ Serveur MCP créé");
|
|
59
128
|
|
|
60
129
|
// Restaurer les tunnels locaux au démarrage
|
|
@@ -75,9 +144,12 @@ server.registerTool(
|
|
|
75
144
|
const stats = {
|
|
76
145
|
queue: queue.getStats(),
|
|
77
146
|
pool: ssh.getPoolStats(),
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
147
|
+
// Secrets masqués (api_key, password, htpasswd_pass, …)
|
|
148
|
+
servers: utils.redactSensitiveObject(await servers.listServers()),
|
|
149
|
+
apis: utils.redactSensitiveObject(await apis.listApis()),
|
|
150
|
+
crashed: queue.getCrashedJobs().length,
|
|
151
|
+
version: ORCH_VERSION,
|
|
152
|
+
readOnly: !!config.readOnly
|
|
81
153
|
};
|
|
82
154
|
|
|
83
155
|
if (params.verbose) {
|
|
@@ -101,13 +173,16 @@ server.registerTool(
|
|
|
101
173
|
host: z.string().describe("Adresse IP ou nom d'hôte du serveur"),
|
|
102
174
|
user: z.string().describe("Nom d'utilisateur pour la connexion"),
|
|
103
175
|
keyPath: z.string().optional().describe("Chemin absolu vers la clé privée SSH."),
|
|
104
|
-
password: z.string().optional().describe("Mot de passe pour la connexion.")
|
|
176
|
+
password: z.string().optional().describe("Mot de passe pour la connexion."),
|
|
177
|
+
readonly: z.boolean().optional().describe("Si true, refuse les écritures/exec mutantes sur cet alias."),
|
|
178
|
+
port: z.number().optional().describe("Port SSH (défaut 22).")
|
|
105
179
|
}).refine(data => data.keyPath || data.password, {
|
|
106
180
|
message: "Vous devez fournir au moins une méthode d'authentification ('keyPath' ou 'password')."
|
|
107
181
|
})
|
|
108
182
|
},
|
|
109
183
|
async (params) => {
|
|
110
184
|
try {
|
|
185
|
+
guardWritable('server_add');
|
|
111
186
|
const { alias, ...serverConfig } = params;
|
|
112
187
|
const result = await servers.addServer(alias, serverConfig);
|
|
113
188
|
return { content: [{ type: "text", text: result.message }] };
|
|
@@ -130,7 +205,7 @@ server.registerTool(
|
|
|
130
205
|
inputSchema: z.object({})
|
|
131
206
|
},
|
|
132
207
|
async () => {
|
|
133
|
-
const serverList = await servers.listServers();
|
|
208
|
+
const serverList = utils.redactSensitiveObject(await servers.listServers());
|
|
134
209
|
return { content: [{ type: "text", text: JSON.stringify(serverList, null, 2) }] };
|
|
135
210
|
}
|
|
136
211
|
);
|
|
@@ -146,6 +221,7 @@ server.registerTool(
|
|
|
146
221
|
},
|
|
147
222
|
async (params) => {
|
|
148
223
|
try {
|
|
224
|
+
guardWritable('server_remove');
|
|
149
225
|
const result = await servers.removeServer(params.alias);
|
|
150
226
|
return { content: [{ type: "text", text: result.message }] };
|
|
151
227
|
} catch (e) {
|
|
@@ -159,6 +235,422 @@ server.registerTool(
|
|
|
159
235
|
}
|
|
160
236
|
);
|
|
161
237
|
|
|
238
|
+
// --- GROUPES D'ALIAS ---
|
|
239
|
+
|
|
240
|
+
server.registerTool(
|
|
241
|
+
"server_group_list",
|
|
242
|
+
{
|
|
243
|
+
title: "Lister les groupes d'alias",
|
|
244
|
+
description: "Affiche les groupes de serveurs (oci, contabo, …). Utilisable dans task_exec via alias 'group:nom' ou le nom du groupe.",
|
|
245
|
+
inputSchema: z.object({})
|
|
246
|
+
},
|
|
247
|
+
async () => {
|
|
248
|
+
const g = await groups.listGroups();
|
|
249
|
+
return { content: [{ type: "text", text: JSON.stringify(g, null, 2) }] };
|
|
250
|
+
}
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
server.registerTool(
|
|
254
|
+
"server_group_set",
|
|
255
|
+
{
|
|
256
|
+
title: "Créer/mettre à jour un groupe d'alias",
|
|
257
|
+
description: "Définit un groupe nommé pointant vers une liste d'alias serveurs existants.",
|
|
258
|
+
inputSchema: z.object({
|
|
259
|
+
name: z.string().describe("Nom du groupe (ex: oci, relays)"),
|
|
260
|
+
aliases: z.array(z.string()).min(1).describe("Liste d'alias serveurs")
|
|
261
|
+
})
|
|
262
|
+
},
|
|
263
|
+
async (params) => {
|
|
264
|
+
try {
|
|
265
|
+
guardWritable('server_group_set');
|
|
266
|
+
const result = await groups.setGroup(params.name, params.aliases);
|
|
267
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
268
|
+
} catch (e) {
|
|
269
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "server_group_set", errorMessage: e.message }, null, 2) }], isError: true };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
server.registerTool(
|
|
275
|
+
"server_group_remove",
|
|
276
|
+
{
|
|
277
|
+
title: "Supprimer un groupe d'alias",
|
|
278
|
+
description: "Supprime un groupe (ne supprime pas les serveurs).",
|
|
279
|
+
inputSchema: z.object({
|
|
280
|
+
name: z.string().describe("Nom du groupe")
|
|
281
|
+
})
|
|
282
|
+
},
|
|
283
|
+
async (params) => {
|
|
284
|
+
try {
|
|
285
|
+
guardWritable('server_group_remove');
|
|
286
|
+
const result = await groups.removeGroup(params.name);
|
|
287
|
+
return { content: [{ type: "text", text: result.message }] };
|
|
288
|
+
} catch (e) {
|
|
289
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "server_group_remove", errorMessage: e.message }, null, 2) }], isError: true };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
server.registerTool(
|
|
295
|
+
"fleet_status",
|
|
296
|
+
{
|
|
297
|
+
title: "Status SSH de tout le parc",
|
|
298
|
+
description: "Ping SSH parallèle : latence, uptime/load/disk légers, clés manquantes. Filtrable par alias, liste, 'all' ou group:nom.",
|
|
299
|
+
inputSchema: z.object({
|
|
300
|
+
alias: z.union([z.string(), z.array(z.string())]).optional().describe("Alias, group:x, all, ou tableau. Défaut: tout le parc."),
|
|
301
|
+
timeout_s: z.number().optional().default(15).describe("Timeout par serveur en secondes")
|
|
302
|
+
})
|
|
303
|
+
},
|
|
304
|
+
async (params) => {
|
|
305
|
+
try {
|
|
306
|
+
let aliases;
|
|
307
|
+
if (params.alias) {
|
|
308
|
+
aliases = await groups.resolveAliases(params.alias);
|
|
309
|
+
}
|
|
310
|
+
const result = await fleet.fleetStatus({
|
|
311
|
+
aliases,
|
|
312
|
+
timeoutMs: (params.timeout_s || 15) * 1000
|
|
313
|
+
});
|
|
314
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
315
|
+
} catch (e) {
|
|
316
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "fleet_status", errorMessage: e.message }, null, 2) }], isError: true };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
server.registerTool(
|
|
322
|
+
"infra_audit",
|
|
323
|
+
{
|
|
324
|
+
title: "Audit synthèse du parc (1 appel)",
|
|
325
|
+
description: "Vue combinée : version orchestrator, serveurs (secrets masqués), groupes, notes, projets, crashed jobs, pool stats. Idéal en début de session.",
|
|
326
|
+
inputSchema: z.object({
|
|
327
|
+
compact: z.boolean().optional().describe("Réponse tronquée (tokens)")
|
|
328
|
+
})
|
|
329
|
+
},
|
|
330
|
+
async (params) => {
|
|
331
|
+
try {
|
|
332
|
+
const [serverList, groupList, noteList, projectList] = await Promise.all([
|
|
333
|
+
servers.listServers(),
|
|
334
|
+
groups.listGroups(),
|
|
335
|
+
notes.list(),
|
|
336
|
+
projects.list()
|
|
337
|
+
]);
|
|
338
|
+
const audit = {
|
|
339
|
+
version: ORCH_VERSION,
|
|
340
|
+
readOnly: !!config.readOnly,
|
|
341
|
+
compact: !!config.compact,
|
|
342
|
+
dataDir: config.dataDir,
|
|
343
|
+
servers: utils.redactSensitiveObject(serverList),
|
|
344
|
+
groups: groupList,
|
|
345
|
+
projects: Object.keys(projectList),
|
|
346
|
+
notes: noteList,
|
|
347
|
+
queue: queue.getStats(),
|
|
348
|
+
crashed: queue.getCrashedJobs().map(j => ({ id: j.id, type: j.type, alias: j.alias, crashedAt: j.crashedAt })),
|
|
349
|
+
pool: ssh.getPoolStats(),
|
|
350
|
+
timestamp: new Date().toISOString()
|
|
351
|
+
};
|
|
352
|
+
return jsonResult(audit, params);
|
|
353
|
+
} catch (e) {
|
|
354
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "infra_audit", errorMessage: e.message }, null, 2) }], isError: true };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// --- PROJETS (A) ---
|
|
360
|
+
|
|
361
|
+
server.registerTool(
|
|
362
|
+
"project_list",
|
|
363
|
+
{
|
|
364
|
+
title: "Lister les projets enregistrés",
|
|
365
|
+
description: "Registre sémantique projet ↔ serveur(s) ↔ paths local/remote.",
|
|
366
|
+
inputSchema: z.object({})
|
|
367
|
+
},
|
|
368
|
+
async () => jsonResult(await projects.list())
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
server.registerTool(
|
|
372
|
+
"project_get",
|
|
373
|
+
{
|
|
374
|
+
title: "Détail d'un projet",
|
|
375
|
+
description: "Retourne la config complète d'un projet (local, servers, ignore, runtime).",
|
|
376
|
+
inputSchema: z.object({ name: z.string() })
|
|
377
|
+
},
|
|
378
|
+
async (params) => {
|
|
379
|
+
try {
|
|
380
|
+
return jsonResult(await projects.get(params.name));
|
|
381
|
+
} catch (e) {
|
|
382
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "project_get", errorMessage: e.message }, null, 2) }], isError: true };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
server.registerTool(
|
|
388
|
+
"project_set",
|
|
389
|
+
{
|
|
390
|
+
title: "Créer/mettre à jour un projet",
|
|
391
|
+
description: "Enregistre un projet. Ex: { name:'p-image', local:{path:'…'}, servers:{prod:{alias:'fkomprodmini2_prod', path:'/home/ubuntu/p-image', runtime:{pm2:'p-image', port:5002}}}, ignore:['node_modules'] }",
|
|
392
|
+
inputSchema: z.object({
|
|
393
|
+
name: z.string(),
|
|
394
|
+
description: z.string().optional(),
|
|
395
|
+
local: z.object({ path: z.string() }).optional(),
|
|
396
|
+
servers: z.record(z.object({
|
|
397
|
+
alias: z.string(),
|
|
398
|
+
path: z.string(),
|
|
399
|
+
runtime: z.record(z.any()).optional(),
|
|
400
|
+
url: z.string().optional()
|
|
401
|
+
})).optional(),
|
|
402
|
+
ignore: z.array(z.string()).optional(),
|
|
403
|
+
tags: z.array(z.string()).optional(),
|
|
404
|
+
network: z.record(z.any()).optional()
|
|
405
|
+
})
|
|
406
|
+
},
|
|
407
|
+
async (params) => {
|
|
408
|
+
try {
|
|
409
|
+
guardWritable('project_set');
|
|
410
|
+
const { name, ...fields } = params;
|
|
411
|
+
return jsonResult(await projects.set(name, fields));
|
|
412
|
+
} catch (e) {
|
|
413
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "project_set", errorMessage: e.message }, null, 2) }], isError: true };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
server.registerTool(
|
|
419
|
+
"project_remove",
|
|
420
|
+
{
|
|
421
|
+
title: "Supprimer un projet du registre",
|
|
422
|
+
inputSchema: z.object({ name: z.string() })
|
|
423
|
+
},
|
|
424
|
+
async (params) => {
|
|
425
|
+
try {
|
|
426
|
+
guardWritable('project_remove');
|
|
427
|
+
return jsonResult(await projects.remove(params.name));
|
|
428
|
+
} catch (e) {
|
|
429
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "project_remove", errorMessage: e.message }, null, 2) }], isError: true };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
server.registerTool(
|
|
435
|
+
"project_resolve",
|
|
436
|
+
{
|
|
437
|
+
title: "Résoudre un projet en sources local/remote",
|
|
438
|
+
description: "Retourne { local, remote, ignore, runtime } prêts pour file_* / project_diff.",
|
|
439
|
+
inputSchema: z.object({
|
|
440
|
+
name: z.string(),
|
|
441
|
+
role: z.string().optional().describe("Rôle serveur (prod, staging…). Défaut: prod ou premier.")
|
|
442
|
+
})
|
|
443
|
+
},
|
|
444
|
+
async (params) => {
|
|
445
|
+
try {
|
|
446
|
+
return jsonResult(await projects.resolve(params.name, params.role || null));
|
|
447
|
+
} catch (e) {
|
|
448
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "project_resolve", errorMessage: e.message }, null, 2) }], isError: true };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// --- PROJECT DIFF (C) ---
|
|
454
|
+
|
|
455
|
+
server.registerTool(
|
|
456
|
+
"project_diff",
|
|
457
|
+
{
|
|
458
|
+
title: "Diff local ↔ remote d'un projet enregistré",
|
|
459
|
+
description: "Compare le path local et le path remote du projet (ignorePatterns du registre). includeDiff pour le détail.",
|
|
460
|
+
inputSchema: z.object({
|
|
461
|
+
name: z.string(),
|
|
462
|
+
role: z.string().optional(),
|
|
463
|
+
includeDiff: z.boolean().optional().default(false),
|
|
464
|
+
compact: z.boolean().optional()
|
|
465
|
+
})
|
|
466
|
+
},
|
|
467
|
+
async (params) => {
|
|
468
|
+
try {
|
|
469
|
+
const resolved = await projects.resolve(params.name, params.role || null);
|
|
470
|
+
if (!resolved.local || !resolved.remote) {
|
|
471
|
+
throw new Error("Le projet doit avoir local.path ET servers.<role>.path pour project_diff.");
|
|
472
|
+
}
|
|
473
|
+
const result = await diffEngine.diffFolders(resolved.local, resolved.remote, {
|
|
474
|
+
ignorePatterns: resolved.ignore,
|
|
475
|
+
includeDiff: params.includeDiff === true
|
|
476
|
+
});
|
|
477
|
+
const summary = {
|
|
478
|
+
project: params.name,
|
|
479
|
+
role: resolved.role,
|
|
480
|
+
local: resolved.local,
|
|
481
|
+
remote: resolved.remote,
|
|
482
|
+
stats: result.stats,
|
|
483
|
+
only_in_local: result.only_in_source1?.length ?? 0,
|
|
484
|
+
only_in_remote: result.only_in_source2?.length ?? 0,
|
|
485
|
+
identical: result.identical?.length ?? 0,
|
|
486
|
+
modified: result.modified?.length ?? 0,
|
|
487
|
+
modified_paths: (result.modified || []).slice(0, 30).map(m => m.path),
|
|
488
|
+
only_local_sample: (result.only_in_source1 || []).slice(0, 20),
|
|
489
|
+
only_remote_sample: (result.only_in_source2 || []).slice(0, 20)
|
|
490
|
+
};
|
|
491
|
+
if (params.includeDiff) summary.detail = result;
|
|
492
|
+
return jsonResult(summary, params);
|
|
493
|
+
} catch (e) {
|
|
494
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "project_diff", errorMessage: e.message }, null, 2) }], isError: true };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
// --- WORK SESSIONS (B) ---
|
|
500
|
+
|
|
501
|
+
server.registerTool(
|
|
502
|
+
"work_start",
|
|
503
|
+
{
|
|
504
|
+
title: "Démarrer une session de travail",
|
|
505
|
+
description: "Ouvre un journal d'intervention (option snapshot). Retourne sessionId. Fermer avec work_end (met à jour server_note).",
|
|
506
|
+
inputSchema: z.object({
|
|
507
|
+
alias: z.string().optional(),
|
|
508
|
+
project: z.string().optional(),
|
|
509
|
+
tag: z.string().optional(),
|
|
510
|
+
message: z.string().optional(),
|
|
511
|
+
snapshot: z.boolean().optional().default(false).describe("Snapshot remote avant travail"),
|
|
512
|
+
paths: z.array(z.string()).optional().describe("Paths à snapshotter si snapshot:true")
|
|
513
|
+
})
|
|
514
|
+
},
|
|
515
|
+
async (params) => {
|
|
516
|
+
try {
|
|
517
|
+
guardWritable('work_start');
|
|
518
|
+
if (params.alias) await guardAliasWritable(params.alias, 'work_start');
|
|
519
|
+
const session = await workSession.start(params);
|
|
520
|
+
return jsonResult(session);
|
|
521
|
+
} catch (e) {
|
|
522
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "work_start", errorMessage: e.message }, null, 2) }], isError: true };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
server.registerTool(
|
|
528
|
+
"work_log",
|
|
529
|
+
{
|
|
530
|
+
title: "Logger un événement dans une work session",
|
|
531
|
+
inputSchema: z.object({
|
|
532
|
+
id: z.string(),
|
|
533
|
+
type: z.string().describe("ex: file_edit, task_exec, note"),
|
|
534
|
+
detail: z.string().optional(),
|
|
535
|
+
path: z.string().optional()
|
|
536
|
+
})
|
|
537
|
+
},
|
|
538
|
+
async (params) => {
|
|
539
|
+
try {
|
|
540
|
+
guardWritable('work_log');
|
|
541
|
+
const r = await workSession.log(params.id, {
|
|
542
|
+
type: params.type,
|
|
543
|
+
detail: params.detail,
|
|
544
|
+
path: params.path
|
|
545
|
+
});
|
|
546
|
+
return jsonResult(r);
|
|
547
|
+
} catch (e) {
|
|
548
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "work_log", errorMessage: e.message }, null, 2) }], isError: true };
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
server.registerTool(
|
|
554
|
+
"work_list",
|
|
555
|
+
{
|
|
556
|
+
title: "Lister les work sessions",
|
|
557
|
+
inputSchema: z.object({
|
|
558
|
+
includeHistory: z.boolean().optional().default(false)
|
|
559
|
+
})
|
|
560
|
+
},
|
|
561
|
+
async (params) => jsonResult(await workSession.list({ includeHistory: params.includeHistory }))
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
server.registerTool(
|
|
565
|
+
"work_end",
|
|
566
|
+
{
|
|
567
|
+
title: "Clôturer une work session",
|
|
568
|
+
description: "Ferme la session et met à jour last_intervention sur la note serveur (note:true).",
|
|
569
|
+
inputSchema: z.object({
|
|
570
|
+
id: z.string(),
|
|
571
|
+
summary: z.string().optional(),
|
|
572
|
+
note: z.boolean().optional().default(true)
|
|
573
|
+
})
|
|
574
|
+
},
|
|
575
|
+
async (params) => {
|
|
576
|
+
try {
|
|
577
|
+
guardWritable('work_end');
|
|
578
|
+
return jsonResult(await workSession.end(params.id, {
|
|
579
|
+
summary: params.summary,
|
|
580
|
+
note: params.note !== false
|
|
581
|
+
}));
|
|
582
|
+
} catch (e) {
|
|
583
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "work_end", errorMessage: e.message }, null, 2) }], isError: true };
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
// --- INVENTORY (D) ---
|
|
589
|
+
|
|
590
|
+
server.registerTool(
|
|
591
|
+
"server_inventory",
|
|
592
|
+
{
|
|
593
|
+
title: "Inventaire léger d'un serveur (cache 10 min)",
|
|
594
|
+
description: "hostname, disk, mem, pm2, docker, top-level $HOME, présence tailscale. force:true pour rafraîchir.",
|
|
595
|
+
inputSchema: z.object({
|
|
596
|
+
alias: z.string(),
|
|
597
|
+
force: z.boolean().optional().default(false),
|
|
598
|
+
compact: z.boolean().optional()
|
|
599
|
+
})
|
|
600
|
+
},
|
|
601
|
+
async (params) => {
|
|
602
|
+
try {
|
|
603
|
+
const inv = await inventory.get(params.alias, { force: params.force === true });
|
|
604
|
+
return jsonResult(inv, params);
|
|
605
|
+
} catch (e) {
|
|
606
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "server_inventory", errorMessage: e.message }, null, 2) }], isError: true };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
// --- SSH TRUST / AUTHORIZE KEY (v11.6) ---
|
|
612
|
+
|
|
613
|
+
server.registerTool(
|
|
614
|
+
"ssh_authorize_key",
|
|
615
|
+
{
|
|
616
|
+
title: "Autoriser une pubkey SSH sur un serveur (trust)",
|
|
617
|
+
description: "Ajoute une clé PUBLIQUE dans authorized_keys d'un serveur. source: string|local_path|.pub|alias (dérive .pub depuis keyPath). Ne copie JAMAIS de private key. dry_run:true par défaut.",
|
|
618
|
+
inputSchema: z.object({
|
|
619
|
+
target_alias: z.string().describe("Serveur où écrire authorized_keys"),
|
|
620
|
+
source: z.object({
|
|
621
|
+
type: z.enum(['string', 'local_path', 'alias']),
|
|
622
|
+
pubkey: z.string().optional(),
|
|
623
|
+
path: z.string().optional(),
|
|
624
|
+
alias: z.string().optional()
|
|
625
|
+
}).describe("Origine de la pubkey"),
|
|
626
|
+
user: z.string().optional().describe("User distant (défaut: user SSH de l'alias)"),
|
|
627
|
+
authorized_keys_path: z.string().optional(),
|
|
628
|
+
comment: z.string().optional(),
|
|
629
|
+
dry_run: z.boolean().optional().default(true),
|
|
630
|
+
force: z.boolean().optional().default(false)
|
|
631
|
+
})
|
|
632
|
+
},
|
|
633
|
+
async (params) => {
|
|
634
|
+
try {
|
|
635
|
+
if (!params.dry_run) {
|
|
636
|
+
await guardAliasWritable(params.target_alias, 'ssh_authorize_key');
|
|
637
|
+
}
|
|
638
|
+
const result = await sshTrust.authorizeKey({
|
|
639
|
+
target_alias: params.target_alias,
|
|
640
|
+
source: params.source,
|
|
641
|
+
user: params.user,
|
|
642
|
+
authorized_keys_path: params.authorized_keys_path,
|
|
643
|
+
comment: params.comment,
|
|
644
|
+
dryRun: params.dry_run !== false,
|
|
645
|
+
force: params.force === true
|
|
646
|
+
});
|
|
647
|
+
return jsonResult(result);
|
|
648
|
+
} catch (e) {
|
|
649
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "ssh_authorize_key", errorMessage: e.message }, null, 2) }], isError: true };
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
);
|
|
653
|
+
|
|
162
654
|
// --- OUTILS DE SÉCURITÉ / POLICIES ---
|
|
163
655
|
|
|
164
656
|
server.registerTool(
|
|
@@ -226,6 +718,7 @@ server.registerTool(
|
|
|
226
718
|
},
|
|
227
719
|
async (params) => {
|
|
228
720
|
try {
|
|
721
|
+
guardWritable('api_add');
|
|
229
722
|
const { alias, ...apiConfig } = params;
|
|
230
723
|
const result = await apis.addApi(alias, apiConfig);
|
|
231
724
|
return { content: [{ type: "text", text: result.message }] };
|
|
@@ -248,7 +741,8 @@ server.registerTool(
|
|
|
248
741
|
inputSchema: z.object({})
|
|
249
742
|
},
|
|
250
743
|
async () => {
|
|
251
|
-
|
|
744
|
+
// Ne jamais renvoyer api_key / htpasswd_pass en clair aux agents
|
|
745
|
+
const apiList = utils.redactSensitiveObject(await apis.listApis());
|
|
252
746
|
return { content: [{ type: "text", text: JSON.stringify(apiList, null, 2) }] };
|
|
253
747
|
}
|
|
254
748
|
);
|
|
@@ -264,6 +758,7 @@ server.registerTool(
|
|
|
264
758
|
},
|
|
265
759
|
async (params) => {
|
|
266
760
|
try {
|
|
761
|
+
guardWritable('api_remove');
|
|
267
762
|
const result = await apis.removeApi(params.alias);
|
|
268
763
|
return { content: [{ type: "text", text: result.message }] };
|
|
269
764
|
} catch (e) {
|
|
@@ -456,7 +951,7 @@ server.registerTool(
|
|
|
456
951
|
},
|
|
457
952
|
async (params) => {
|
|
458
953
|
try {
|
|
459
|
-
const cmd = `sudo fail2ban-client status ${params.jail
|
|
954
|
+
const cmd = params.jail ? `sudo fail2ban-client status ${utils.escapeShellArg(params.jail)}` : 'sudo fail2ban-client status';
|
|
460
955
|
const job = queue.addJob({
|
|
461
956
|
type: 'ssh',
|
|
462
957
|
alias: params.alias,
|
|
@@ -495,7 +990,8 @@ async function waitForJobCompletion(jobId, timeout) {
|
|
|
495
990
|
resolve(null);
|
|
496
991
|
return;
|
|
497
992
|
}
|
|
498
|
-
|
|
993
|
+
// completed | failed | partial | crashed = terminal (plus d'attente)
|
|
994
|
+
if (utils.isTerminalJobStatus(job.status)) {
|
|
499
995
|
clearInterval(interval);
|
|
500
996
|
resolve(job);
|
|
501
997
|
} else if (timeout > 0 && Date.now() - startTime > timeout) {
|
|
@@ -520,14 +1016,22 @@ server.registerTool(
|
|
|
520
1016
|
inputSchema: z.object({
|
|
521
1017
|
alias: z.string().describe("Alias du serveur cible."),
|
|
522
1018
|
direction: z.enum(['upload', 'download', 'server_to_server']),
|
|
523
|
-
local: z.string().optional().describe("Chemin
|
|
524
|
-
remote: z.string().describe("Chemin
|
|
1019
|
+
local: z.string().optional().describe("Chemin local (upload/download) ou chemin distant SOURCE pour server_to_server."),
|
|
1020
|
+
remote: z.string().describe("Chemin distant cible (upload/server_to_server) ou source distante (download)."),
|
|
525
1021
|
source_alias: z.string().optional().describe("Alias source (requis si direction='server_to_server')."),
|
|
526
1022
|
force: z.boolean().optional().default(false).describe("Écraser les fichiers existants sans confirmation."),
|
|
527
1023
|
rappel: z.number().optional().describe("Définit un rappel en secondes.")
|
|
528
1024
|
})
|
|
529
1025
|
},
|
|
530
1026
|
async (params) => {
|
|
1027
|
+
try {
|
|
1028
|
+
await guardTransferWritable(params, 'task_transfer');
|
|
1029
|
+
if (params.direction === 'server_to_server' && !params.source_alias) {
|
|
1030
|
+
throw new Error("source_alias requis pour server_to_server.");
|
|
1031
|
+
}
|
|
1032
|
+
} catch (e) {
|
|
1033
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "task_transfer", errorMessage: e.message }, null, 2) }], isError: true };
|
|
1034
|
+
}
|
|
531
1035
|
const job = queue.addJob({ type: 'sftp', ...params, status: 'pending' });
|
|
532
1036
|
history.logTask(job);
|
|
533
1037
|
sftp.executeTransfer(job.id);
|
|
@@ -550,45 +1054,75 @@ server.registerTool(
|
|
|
550
1054
|
"task_exec",
|
|
551
1055
|
{
|
|
552
1056
|
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
|
|
1057
|
+
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, tableau, "all", "group:nom" ou nom de groupe. dry_run:true sur commande destructive = preview sans exécuter.`,
|
|
554
1058
|
inputSchema: z.object({
|
|
555
|
-
alias: z.union([z.string(), z.array(z.string())]).describe("Alias
|
|
1059
|
+
alias: z.union([z.string(), z.array(z.string())]).describe("Alias, tableau, 'all', 'group:oci' ou nom de groupe."),
|
|
556
1060
|
cmd: z.string().describe("La commande complète à exécuter."),
|
|
557
1061
|
timeout: z.number().optional().describe("Timeout en secondes. 0 = pas de limite. Défaut: 600s (10 min)."),
|
|
558
1062
|
skip_policy: z.boolean().optional().default(false).describe("Ignorer la politique de sécurité (blocklist)."),
|
|
1063
|
+
dry_run: z.boolean().optional().default(false).describe("Si true (ou commande destructive sans force), retourne l'analyse sans exécuter."),
|
|
1064
|
+
force: z.boolean().optional().default(false).describe("Exécuter même si la commande est détectée destructive."),
|
|
559
1065
|
rappel: z.number().optional().describe("Définit un rappel en secondes.")
|
|
560
1066
|
})
|
|
561
1067
|
},
|
|
562
1068
|
async (params) => {
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
1069
|
+
try {
|
|
1070
|
+
guardWritable('task_exec');
|
|
1071
|
+
const aliases = await groups.resolveAliases(params.alias);
|
|
1072
|
+
// RO par alias
|
|
1073
|
+
for (const a of aliases) {
|
|
1074
|
+
if (await servers.isAliasReadOnly(a)) {
|
|
1075
|
+
throw new Error(`Alias '${a}' est readonly:true — task_exec refusé.`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
const destructive = utils.isDestructiveCommand(params.cmd);
|
|
566
1079
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
1080
|
+
if (params.dry_run || (destructive && !params.force)) {
|
|
1081
|
+
return {
|
|
1082
|
+
content: [{
|
|
1083
|
+
type: "text",
|
|
1084
|
+
text: JSON.stringify({
|
|
1085
|
+
dry_run: true,
|
|
1086
|
+
would_execute: !destructive || params.force,
|
|
1087
|
+
destructive,
|
|
1088
|
+
aliases,
|
|
1089
|
+
cmd: params.cmd,
|
|
1090
|
+
note: destructive && !params.force
|
|
1091
|
+
? "Commande détectée destructive. Relancez avec force:true pour exécuter, ou dry_run:true pour confirmer l'intention."
|
|
1092
|
+
: "Preview dry_run — aucune commande envoyée."
|
|
1093
|
+
}, null, 2)
|
|
1094
|
+
}]
|
|
1095
|
+
};
|
|
576
1096
|
}
|
|
577
|
-
}
|
|
578
1097
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1098
|
+
if (aliases.length === 1) {
|
|
1099
|
+
const job = queue.addJob({ type: 'ssh', alias: aliases[0], cmd: params.cmd, timeout: params.timeout, skip_policy: params.skip_policy, status: 'pending' });
|
|
1100
|
+
history.logTask(job);
|
|
1101
|
+
ssh.executeCommand(job.id);
|
|
1102
|
+
const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
1103
|
+
if (finalJob) {
|
|
1104
|
+
return { content: [{ type: "text", text: `Résultat direct (tâche ${finalJob.id}):\n${finalJob.output || JSON.stringify(finalJob, null, 2)}` }] };
|
|
1105
|
+
} else {
|
|
1106
|
+
return { content: [{ type: "text", text: buildAsyncMessage(job, "d'exécution") }] };
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const jobs = aliases.map(alias =>
|
|
1111
|
+
queue.addJob({ type: 'ssh', alias, cmd: params.cmd, timeout: params.timeout, skip_policy: params.skip_policy, status: 'pending' })
|
|
1112
|
+
);
|
|
1113
|
+
jobs.forEach(j => { history.logTask(j); ssh.executeCommand(j.id); });
|
|
583
1114
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
1115
|
+
const results = await Promise.all(jobs.map(j => waitForJobCompletion(j.id, config.syncTimeout)));
|
|
1116
|
+
const output = results.map((r, i) => {
|
|
1117
|
+
if (!r) return `[${aliases[i]}] Timeout ou erreur`;
|
|
1118
|
+
if (r.status === 'failed') return `[${aliases[i]}] ERREUR: ${r.error}`;
|
|
1119
|
+
return `[${aliases[i]}] OK\n${r.output || '(vide)'}`;
|
|
1120
|
+
}).join('\n\n---\n\n');
|
|
590
1121
|
|
|
591
|
-
|
|
1122
|
+
return { content: [{ type: "text", text: `Résultats multi-serveur (${aliases.length} cibles):\n\n${output}` }] };
|
|
1123
|
+
} catch (e) {
|
|
1124
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "task_exec", errorMessage: e.message }, null, 2) }], isError: true };
|
|
1125
|
+
}
|
|
592
1126
|
}
|
|
593
1127
|
);
|
|
594
1128
|
|
|
@@ -606,13 +1140,20 @@ server.registerTool(
|
|
|
606
1140
|
"task_queue",
|
|
607
1141
|
{
|
|
608
1142
|
title: "Voir la file d'attente des tâches",
|
|
609
|
-
description: "Affiche
|
|
610
|
-
inputSchema: z.object({
|
|
1143
|
+
description: "Affiche la queue récente. Filtrable par statut et paginable pour éviter de déverser des centaines de jobs dans le contexte.",
|
|
1144
|
+
inputSchema: z.object({
|
|
1145
|
+
status: z.enum(['pending','running','completed','failed','crashed','partial']).optional().describe("Filtre optionnel par statut."),
|
|
1146
|
+
limit: z.number().int().min(1).max(500).optional().default(100).describe("Nombre maximum de jobs retournés."),
|
|
1147
|
+
compact: z.boolean().optional().default(true).describe("Tronque les gros champs output/stderr/diff. Défaut true.")
|
|
1148
|
+
})
|
|
611
1149
|
},
|
|
612
|
-
async () => {
|
|
1150
|
+
async (params) => {
|
|
613
1151
|
const queueState = queue.getQueue();
|
|
614
|
-
|
|
615
|
-
|
|
1152
|
+
let displayQueue = Object.values(queueState).map(formatJobForDisplay);
|
|
1153
|
+
if (params.status) displayQueue = displayQueue.filter(j => j.status === params.status);
|
|
1154
|
+
displayQueue.sort((a,b) => new Date(b.updatedAt || b.createdAt || 0) - new Date(a.updatedAt || a.createdAt || 0));
|
|
1155
|
+
displayQueue = displayQueue.slice(0, params.limit);
|
|
1156
|
+
return jsonResult({ jobs: displayQueue, count: displayQueue.length, total: Object.keys(queueState).length }, params);
|
|
616
1157
|
}
|
|
617
1158
|
);
|
|
618
1159
|
|
|
@@ -638,14 +1179,18 @@ server.registerTool(
|
|
|
638
1179
|
"task_history",
|
|
639
1180
|
{
|
|
640
1181
|
title: "Consulter l'historique des tâches",
|
|
641
|
-
description: "
|
|
1182
|
+
description: "Historique récent des tâches, filtrable par alias et paginable. Utiliser task_status pour le détail complet d'un job précis.",
|
|
642
1183
|
inputSchema: z.object({
|
|
643
|
-
alias: z.string().optional().describe("Filtre
|
|
1184
|
+
alias: z.string().optional().describe("Filtre par alias serveur."),
|
|
1185
|
+
limit: z.number().int().min(1).max(500).optional().default(100).describe("Nombre maximum d'entrées."),
|
|
1186
|
+
offset: z.number().int().min(0).optional().default(0).describe("Décalage pour pagination."),
|
|
1187
|
+
compact: z.boolean().optional().default(true).describe("Tronque les gros champs si présents.")
|
|
644
1188
|
})
|
|
645
1189
|
},
|
|
646
1190
|
async (params) => {
|
|
647
|
-
const historyLogs = await history.getHistory(params);
|
|
648
|
-
|
|
1191
|
+
const historyLogs = await history.getHistory({ alias: params.alias });
|
|
1192
|
+
const page = historyLogs.slice(params.offset, params.offset + params.limit);
|
|
1193
|
+
return jsonResult({ entries: page, count: page.length, total: historyLogs.length, offset: params.offset, limit: params.limit }, params);
|
|
649
1194
|
}
|
|
650
1195
|
);
|
|
651
1196
|
|
|
@@ -658,16 +1203,27 @@ server.registerTool(
|
|
|
658
1203
|
description: "Lance des transferts SFTP multiples avec support de patterns glob (*, ?, []).",
|
|
659
1204
|
inputSchema: z.object({
|
|
660
1205
|
alias: z.string().describe("Alias du serveur cible."),
|
|
661
|
-
direction: z.enum(['upload', 'download']),
|
|
1206
|
+
direction: z.enum(['upload', 'download', 'server_to_server']),
|
|
1207
|
+
source_alias: z.string().optional().describe("Alias source commun pour server_to_server (surchargeable par fichier)."),
|
|
662
1208
|
files: z.array(z.object({
|
|
663
|
-
local: z.string().describe("Chemin local ou
|
|
664
|
-
remote: z.string().describe("Chemin distant")
|
|
665
|
-
|
|
1209
|
+
local: z.string().describe("Chemin local/pattern, ou chemin distant SOURCE en server_to_server."),
|
|
1210
|
+
remote: z.string().describe("Chemin distant cible."),
|
|
1211
|
+
source_alias: z.string().optional().describe("Alias source spécifique à ce fichier en server_to_server.")
|
|
1212
|
+
})).min(1).describe("Liste des fichiers/dossiers à transférer"),
|
|
666
1213
|
force: z.boolean().optional().default(false).describe("Écraser les fichiers existants sans confirmation."),
|
|
667
1214
|
rappel: z.number().optional().describe("Définit un rappel en secondes.")
|
|
668
1215
|
})
|
|
669
1216
|
},
|
|
670
1217
|
async (params) => {
|
|
1218
|
+
try {
|
|
1219
|
+
await guardTransferWritable(params, 'task_transfer_multi');
|
|
1220
|
+
if (params.direction === 'server_to_server') {
|
|
1221
|
+
const missing = params.files.some(f => !(f.source_alias || params.source_alias));
|
|
1222
|
+
if (missing) throw new Error("source_alias requis (global ou par fichier) pour server_to_server.");
|
|
1223
|
+
}
|
|
1224
|
+
} catch (e) {
|
|
1225
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "task_transfer_multi", errorMessage: e.message }, null, 2) }], isError: true };
|
|
1226
|
+
}
|
|
671
1227
|
const job = queue.addJob({
|
|
672
1228
|
type: 'sftp',
|
|
673
1229
|
...params,
|
|
@@ -702,6 +1258,8 @@ server.registerTool(
|
|
|
702
1258
|
})
|
|
703
1259
|
},
|
|
704
1260
|
async (params) => {
|
|
1261
|
+
try { await guardAliasWritable(params.alias, 'task_exec_interactive'); }
|
|
1262
|
+
catch (e) { return { content: [{ type: "text", text: JSON.stringify({ toolName: "task_exec_interactive", errorMessage: e.message }, null, 2) }], isError: true }; }
|
|
705
1263
|
const job = queue.addJob({
|
|
706
1264
|
type: 'ssh',
|
|
707
1265
|
...params,
|
|
@@ -712,7 +1270,10 @@ server.registerTool(
|
|
|
712
1270
|
history.logTask(job);
|
|
713
1271
|
ssh.executeCommand(job.id);
|
|
714
1272
|
|
|
715
|
-
|
|
1273
|
+
// Hybrid wait toujours en ms (config.syncTimeout). params.timeout = timeout COMMANDE en secondes (job).
|
|
1274
|
+
// Ancien bug : params.timeout (secondes) passé tel quel → attente de 30ms au lieu de 30s.
|
|
1275
|
+
const waitMs = utils.toWaitTimeoutMs(undefined, config.syncTimeout);
|
|
1276
|
+
const finalJob = await waitForJobCompletion(job.id, waitMs);
|
|
716
1277
|
if (finalJob) {
|
|
717
1278
|
return { content: [{ type: "text", text: `Résultat commande interactive (tâche ${finalJob.id}):\n${finalJob.output || JSON.stringify(finalJob, null, 2)}` }] };
|
|
718
1279
|
} else {
|
|
@@ -738,13 +1299,17 @@ server.registerTool(
|
|
|
738
1299
|
])).min(1).describe("Liste des commandes à exécuter en séquence (minimum 1)."),
|
|
739
1300
|
continueOnError: z.boolean().optional().default(false).describe("Continuer même si une commande échoue."),
|
|
740
1301
|
timeout: z.number().optional().describe("Timeout global en secondes. 0 = pas de limite. Défaut: 600s (10 min)."),
|
|
1302
|
+
skip_policy: z.boolean().optional().default(false).describe("Ignorer la politique de sécurité (blocklist) pour chaque étape."),
|
|
741
1303
|
rappel: z.number().optional().describe("Définit un rappel en secondes.")
|
|
742
1304
|
})
|
|
743
1305
|
},
|
|
744
1306
|
async (params) => {
|
|
1307
|
+
try { await guardAliasWritable(params.alias, 'task_exec_sequence'); }
|
|
1308
|
+
catch (e) { return { content: [{ type: "text", text: JSON.stringify({ toolName: "task_exec_sequence", errorMessage: e.message }, null, 2) }], isError: true }; }
|
|
745
1309
|
const job = queue.addJob({
|
|
746
1310
|
type: 'ssh_sequence',
|
|
747
1311
|
...params,
|
|
1312
|
+
skip_policy: params.skip_policy,
|
|
748
1313
|
status: 'pending'
|
|
749
1314
|
});
|
|
750
1315
|
history.logTask(job);
|
|
@@ -792,6 +1357,12 @@ server.registerTool(
|
|
|
792
1357
|
}
|
|
793
1358
|
);
|
|
794
1359
|
|
|
1360
|
+
function dispatchJob(job) {
|
|
1361
|
+
if (job.type === 'sftp') sftp.executeTransfer(job.id);
|
|
1362
|
+
else if (job.type === 'ssh') ssh.executeCommand(job.id);
|
|
1363
|
+
else if (job.type === 'ssh_sequence') ssh.executeCommandSequence(job.id);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
795
1366
|
server.registerTool(
|
|
796
1367
|
"task_retry",
|
|
797
1368
|
{
|
|
@@ -803,17 +1374,11 @@ server.registerTool(
|
|
|
803
1374
|
},
|
|
804
1375
|
async (params) => {
|
|
805
1376
|
try {
|
|
1377
|
+
guardWritable('task_retry');
|
|
1378
|
+
const originalJob = queue.getJob(params.id);
|
|
1379
|
+
await guardRetryJobWritable(originalJob, 'task_retry');
|
|
806
1380
|
const newJob = await queue.retryJob(params.id);
|
|
807
|
-
|
|
808
|
-
// Relancer selon le type
|
|
809
|
-
if (newJob.type === 'sftp') {
|
|
810
|
-
sftp.executeTransfer(newJob.id);
|
|
811
|
-
} else if (newJob.type === 'ssh') {
|
|
812
|
-
ssh.executeCommand(newJob.id);
|
|
813
|
-
} else if (newJob.type === 'ssh_sequence') {
|
|
814
|
-
ssh.executeCommandSequence(newJob.id);
|
|
815
|
-
}
|
|
816
|
-
|
|
1381
|
+
dispatchJob(newJob);
|
|
817
1382
|
return { content: [{ type: "text", text: `Tâche ${params.id} relancée avec le nouvel ID: ${newJob.id}` }] };
|
|
818
1383
|
} catch (e) {
|
|
819
1384
|
const errorPayload = {
|
|
@@ -826,6 +1391,82 @@ server.registerTool(
|
|
|
826
1391
|
}
|
|
827
1392
|
);
|
|
828
1393
|
|
|
1394
|
+
server.registerTool(
|
|
1395
|
+
"task_retry_all",
|
|
1396
|
+
{
|
|
1397
|
+
title: "Relancer toutes les tâches crashed/failed",
|
|
1398
|
+
description: "Relance en masse les jobs retryables (crashed et/ou failed). dry_run:true pour lister sans exécuter.",
|
|
1399
|
+
inputSchema: z.object({
|
|
1400
|
+
status: z.enum(['crashed', 'failed', 'both']).optional().default('crashed').describe("Filtrer par statut"),
|
|
1401
|
+
dry_run: z.boolean().optional().default(false),
|
|
1402
|
+
limit: z.number().optional().default(20).describe("Max de jobs à relancer")
|
|
1403
|
+
})
|
|
1404
|
+
},
|
|
1405
|
+
async (params) => {
|
|
1406
|
+
try {
|
|
1407
|
+
if (!params.dry_run) guardWritable('task_retry_all');
|
|
1408
|
+
const filter = params.status === 'both' ? null : params.status;
|
|
1409
|
+
let jobs = queue.getRetryableJobs(filter);
|
|
1410
|
+
if (params.status === 'both') {
|
|
1411
|
+
jobs = queue.getRetryableJobs(null);
|
|
1412
|
+
}
|
|
1413
|
+
jobs = jobs.slice(0, params.limit || 20);
|
|
1414
|
+
if (params.dry_run) {
|
|
1415
|
+
return {
|
|
1416
|
+
content: [{
|
|
1417
|
+
type: "text",
|
|
1418
|
+
text: JSON.stringify({
|
|
1419
|
+
dry_run: true,
|
|
1420
|
+
count: jobs.length,
|
|
1421
|
+
jobs: jobs.map(j => ({ id: j.id, type: j.type, alias: j.alias, status: j.status, cmd: (j.cmd || '').slice(0, 80) }))
|
|
1422
|
+
}, null, 2)
|
|
1423
|
+
}]
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
const results = [];
|
|
1427
|
+
for (const j of jobs) {
|
|
1428
|
+
try {
|
|
1429
|
+
await guardRetryJobWritable(j, 'task_retry_all');
|
|
1430
|
+
const newJob = await queue.retryJob(j.id);
|
|
1431
|
+
dispatchJob(newJob);
|
|
1432
|
+
results.push({ from: j.id, to: newJob.id, ok: true });
|
|
1433
|
+
} catch (e) {
|
|
1434
|
+
results.push({ from: j.id, ok: false, error: e.message });
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
return { content: [{ type: "text", text: JSON.stringify({ retried: results.length, results }, null, 2) }] };
|
|
1438
|
+
} catch (e) {
|
|
1439
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "task_retry_all", errorMessage: e.message }, null, 2) }], isError: true };
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
);
|
|
1443
|
+
|
|
1444
|
+
server.registerTool(
|
|
1445
|
+
"task_purge",
|
|
1446
|
+
{
|
|
1447
|
+
title: "Purger les jobs terminés/crashés de la queue",
|
|
1448
|
+
description: "Supprime de la queue les jobs crashed/failed/completed. dry_run:true pour compter sans supprimer. olderThanDays optionnel.",
|
|
1449
|
+
inputSchema: z.object({
|
|
1450
|
+
status: z.enum(['crashed', 'failed', 'completed', 'all_terminal']).optional().default('crashed'),
|
|
1451
|
+
olderThanDays: z.number().optional().default(0).describe("0 = tous les jobs du statut, sinon âge minimum en jours"),
|
|
1452
|
+
dry_run: z.boolean().optional().default(true).describe("Défaut true (sécurité) — dry_run:false pour purger vraiment")
|
|
1453
|
+
})
|
|
1454
|
+
},
|
|
1455
|
+
async (params) => {
|
|
1456
|
+
try {
|
|
1457
|
+
if (!params.dry_run) guardWritable('task_purge');
|
|
1458
|
+
const result = queue.purgeJobs({
|
|
1459
|
+
status: params.status,
|
|
1460
|
+
olderThanDays: params.olderThanDays || 0,
|
|
1461
|
+
dryRun: params.dry_run !== false
|
|
1462
|
+
});
|
|
1463
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1464
|
+
} catch (e) {
|
|
1465
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "task_purge", errorMessage: e.message }, null, 2) }], isError: true };
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
);
|
|
1469
|
+
|
|
829
1470
|
server.registerTool(
|
|
830
1471
|
"task_logs",
|
|
831
1472
|
{
|
|
@@ -834,7 +1475,8 @@ server.registerTool(
|
|
|
834
1475
|
inputSchema: z.object({
|
|
835
1476
|
level: z.enum(['error', 'warn', 'info', 'debug']).optional().describe("Filtrer par niveau de log."),
|
|
836
1477
|
search: z.string().optional().describe("Rechercher dans les messages."),
|
|
837
|
-
limit: z.number().optional().default(50).describe("Nombre de logs à afficher.")
|
|
1478
|
+
limit: z.number().int().min(1).max(500).optional().default(50).describe("Nombre de logs à afficher (1..500)."),
|
|
1479
|
+
compact: z.boolean().optional().default(true).describe("Tronque les messages exceptionnellement volumineux.")
|
|
838
1480
|
})
|
|
839
1481
|
},
|
|
840
1482
|
async (params) => {
|
|
@@ -842,7 +1484,7 @@ server.registerTool(
|
|
|
842
1484
|
level: params.level,
|
|
843
1485
|
search: params.search
|
|
844
1486
|
}).slice(-params.limit);
|
|
845
|
-
return {
|
|
1487
|
+
return jsonResult({ logs, count: logs.length }, params);
|
|
846
1488
|
}
|
|
847
1489
|
);
|
|
848
1490
|
|
|
@@ -856,13 +1498,13 @@ server.registerTool(
|
|
|
856
1498
|
inputSchema: z.object({
|
|
857
1499
|
alias: z.string().describe("Alias du serveur cible."),
|
|
858
1500
|
app: z.string().optional().describe("Nom de l'application PM2 (optionnel, toutes par défaut)."),
|
|
859
|
-
lines: z.number().optional().default(100).describe("Nombre de lignes à récupérer."),
|
|
1501
|
+
lines: z.number().int().min(1).max(100000).optional().default(100).describe("Nombre de lignes à récupérer (1..100000)."),
|
|
860
1502
|
errors: z.boolean().optional().default(false).describe("Récupérer uniquement les erreurs (stderr).")
|
|
861
1503
|
})
|
|
862
1504
|
},
|
|
863
1505
|
async (params) => {
|
|
864
1506
|
let cmd = 'pm2 logs';
|
|
865
|
-
if (params.app) cmd += ` ${params.app}`;
|
|
1507
|
+
if (params.app) cmd += ` ${utils.escapeShellArg(params.app)}`;
|
|
866
1508
|
if (params.errors) cmd += ' --err';
|
|
867
1509
|
cmd += ` --lines ${params.lines} --nostream`;
|
|
868
1510
|
|
|
@@ -886,14 +1528,14 @@ server.registerTool(
|
|
|
886
1528
|
inputSchema: z.object({
|
|
887
1529
|
alias: z.string().describe("Alias du serveur cible."),
|
|
888
1530
|
container: z.string().describe("Nom ou ID du container Docker."),
|
|
889
|
-
lines: z.number().optional().default(100).describe("Nombre de lignes à récupérer."),
|
|
1531
|
+
lines: z.number().int().min(1).max(100000).optional().default(100).describe("Nombre de lignes à récupérer (1..100000)."),
|
|
890
1532
|
since: z.string().optional().describe("Logs depuis (ex: '5m', '1h', '2024-01-01')."),
|
|
891
1533
|
timestamps: z.boolean().optional().default(false).describe("Afficher les timestamps.")
|
|
892
1534
|
})
|
|
893
1535
|
},
|
|
894
1536
|
async (params) => {
|
|
895
1537
|
let cmd = `docker logs --tail ${params.lines}`;
|
|
896
|
-
if (params.since) cmd += ` --since ${params.since}`;
|
|
1538
|
+
if (params.since) cmd += ` --since ${utils.escapeShellArg(params.since)}`;
|
|
897
1539
|
if (params.timestamps) cmd += ' --timestamps';
|
|
898
1540
|
cmd += ` ${utils.escapeShellArg(params.container)}`;
|
|
899
1541
|
|
|
@@ -918,7 +1560,7 @@ server.registerTool(
|
|
|
918
1560
|
inputSchema: z.object({
|
|
919
1561
|
alias: z.string().describe("Alias du serveur cible."),
|
|
920
1562
|
filepath: z.string().describe("Chemin absolu du fichier à lire."),
|
|
921
|
-
lines: z.number().optional().default(50).describe("Nombre de lignes à afficher.")
|
|
1563
|
+
lines: z.number().int().min(1).max(100000).optional().default(50).describe("Nombre de lignes à afficher (1..100000).")
|
|
922
1564
|
})
|
|
923
1565
|
},
|
|
924
1566
|
async (params) => {
|
|
@@ -1182,7 +1824,7 @@ server.registerTool(
|
|
|
1182
1824
|
return { content: [{ type: "text", text: detail }] };
|
|
1183
1825
|
}
|
|
1184
1826
|
|
|
1185
|
-
const guide = `=== GUIDE MCP ORCHESTRATOR
|
|
1827
|
+
const guide = `=== GUIDE MCP ORCHESTRATOR v${ORCH_VERSION} ===
|
|
1186
1828
|
|
|
1187
1829
|
OUTILS DISPONIBLES (${allTools.length}):
|
|
1188
1830
|
${allTools.map(t => ` ${t.name}: ${t.desc}`).join('\n')}
|
|
@@ -1287,6 +1929,12 @@ server.registerTool(
|
|
|
1287
1929
|
},
|
|
1288
1930
|
async (params) => {
|
|
1289
1931
|
try {
|
|
1932
|
+
if (!params.dryRun) {
|
|
1933
|
+
guardWritable('file_write');
|
|
1934
|
+
if (params.source?.type === 'remote' && params.source.alias) {
|
|
1935
|
+
await guardAliasWritable(params.source.alias, 'file_write');
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1290
1938
|
const source = resolveSource(params.source);
|
|
1291
1939
|
const result = await fileOps.writeFile(source, params.content, params.encoding, {
|
|
1292
1940
|
createDirs: params.createDirs,
|
|
@@ -1326,6 +1974,12 @@ server.registerTool(
|
|
|
1326
1974
|
},
|
|
1327
1975
|
async (params) => {
|
|
1328
1976
|
try {
|
|
1977
|
+
if (!params.dryRun) {
|
|
1978
|
+
guardWritable('file_edit');
|
|
1979
|
+
if (params.source?.type === 'remote' && params.source.alias) {
|
|
1980
|
+
await guardAliasWritable(params.source.alias, 'file_edit');
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1329
1983
|
const source = resolveSource(params.source);
|
|
1330
1984
|
const result = await fileOps.editFile(source, {
|
|
1331
1985
|
oldString: params.oldString,
|
|
@@ -1516,16 +2170,23 @@ server.registerTool(
|
|
|
1516
2170
|
"shell_exec",
|
|
1517
2171
|
{
|
|
1518
2172
|
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.",
|
|
2173
|
+
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. Respecte la blocklist (skip_policy:true pour forcer).",
|
|
1520
2174
|
inputSchema: z.object({
|
|
1521
2175
|
sessionId: z.string().describe("ID de session obtenu via shell_create"),
|
|
1522
2176
|
command: z.string().describe("Commande à exécuter"),
|
|
1523
|
-
timeout: z.number().optional().describe("Timeout en secondes pour cette commande (0=infini)")
|
|
2177
|
+
timeout: z.number().optional().describe("Timeout en secondes pour cette commande (0=infini)"),
|
|
2178
|
+
skip_policy: z.boolean().optional().default(false).describe("Ignorer la politique de sécurité (blocklist).")
|
|
1524
2179
|
})
|
|
1525
2180
|
},
|
|
1526
2181
|
async (params) => {
|
|
1527
2182
|
try {
|
|
1528
|
-
const
|
|
2183
|
+
const sessionInfo = shellSessions.getSessionInfo(params.sessionId);
|
|
2184
|
+
if (!sessionInfo) throw new Error(`Session '${params.sessionId}' introuvable.`);
|
|
2185
|
+
await guardAliasWritable(sessionInfo.alias, 'shell_exec');
|
|
2186
|
+
const result = await shellSessions.execInSession(params.sessionId, params.command, {
|
|
2187
|
+
timeoutSec: params.timeout,
|
|
2188
|
+
skip_policy: params.skip_policy
|
|
2189
|
+
});
|
|
1529
2190
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1530
2191
|
} catch (e) {
|
|
1531
2192
|
const errorPayload = { toolName: "shell_exec", errorCode: "SHELL_EXEC_ERROR", errorMessage: e.message };
|
|
@@ -1699,7 +2360,12 @@ server.registerTool(
|
|
|
1699
2360
|
if (params.target.type === 'remote' && !params.target.alias) {
|
|
1700
2361
|
throw new Error("alias requis quand type='remote'.");
|
|
1701
2362
|
}
|
|
1702
|
-
// Sécurité : restauration réelle
|
|
2363
|
+
// Sécurité : restauration réelle soumise aux policies globales/alias.
|
|
2364
|
+
if (!params.dryRun) {
|
|
2365
|
+
guardWritable('snapshot_restore');
|
|
2366
|
+
if (params.target.type === 'remote') await guardAliasWritable(params.target.alias, 'snapshot_restore');
|
|
2367
|
+
}
|
|
2368
|
+
// Restauration réelle bloquée sans force explicite
|
|
1703
2369
|
if (!params.dryRun && !params.force) {
|
|
1704
2370
|
const errorPayload = {
|
|
1705
2371
|
toolName: "snapshot_restore",
|
|
@@ -1733,6 +2399,7 @@ server.registerTool(
|
|
|
1733
2399
|
},
|
|
1734
2400
|
async (params) => {
|
|
1735
2401
|
try {
|
|
2402
|
+
guardWritable('snapshot_delete');
|
|
1736
2403
|
const result = await snapshotManager.deleteSnapshot(params.snapshotId);
|
|
1737
2404
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1738
2405
|
} catch (e) {
|
|
@@ -1761,6 +2428,7 @@ server.registerTool(
|
|
|
1761
2428
|
},
|
|
1762
2429
|
async (params) => {
|
|
1763
2430
|
try {
|
|
2431
|
+
guardWritable('server_note_set');
|
|
1764
2432
|
const { alias, ...fields } = params;
|
|
1765
2433
|
const result = await notes.set(alias, fields);
|
|
1766
2434
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
@@ -1823,6 +2491,7 @@ server.registerTool(
|
|
|
1823
2491
|
},
|
|
1824
2492
|
async (params) => {
|
|
1825
2493
|
try {
|
|
2494
|
+
guardWritable('server_note_remove');
|
|
1826
2495
|
const result = await notes.remove(params.alias);
|
|
1827
2496
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1828
2497
|
} catch (e) {
|
|
@@ -1832,32 +2501,79 @@ server.registerTool(
|
|
|
1832
2501
|
}
|
|
1833
2502
|
);
|
|
1834
2503
|
|
|
1835
|
-
// --- VUE SYNTHÉTIQUE DU PARC ---
|
|
2504
|
+
// --- VUE SYNTHÉTIQUE / TOPOLOGIE DU PARC ---
|
|
1836
2505
|
|
|
1837
2506
|
server.registerTool(
|
|
1838
2507
|
"infra_overview",
|
|
1839
2508
|
{
|
|
1840
|
-
title: "
|
|
1841
|
-
description: "Vue
|
|
1842
|
-
inputSchema: z.object({
|
|
2509
|
+
title: "Cartographier le parc ou une machine",
|
|
2510
|
+
description: "Vue légère du parc sans alias; avec alias/group:/all, découverte live des ports, Docker/Compose, PM2, systemd et Nginx avec corrélation domaine → proxy → port → conteneur/service.",
|
|
2511
|
+
inputSchema: z.object({
|
|
2512
|
+
alias: z.string().optional().describe("Alias serveur, 'all' ou 'group:<nom>'. Si fourni, active automatiquement la découverte live."),
|
|
2513
|
+
live: z.boolean().optional().describe("Force la découverte live. Sans alias, live:true sonde tout le parc; défaut false pour garder l'appel initial léger."),
|
|
2514
|
+
force: z.boolean().optional().default(false).describe("Ignore le cache topologique court (2 min) et re-sonde les machines."),
|
|
2515
|
+
compact: z.boolean().optional().default(true).describe("Réduit les gros tableaux/chaînes tout en conservant le graphe domaine→service. Défaut true en mode live."),
|
|
2516
|
+
timeoutMs: z.number().int().min(5000).max(60000).optional().default(25000).describe("Timeout par machine pour la découverte live (5s..60s).")
|
|
2517
|
+
}),
|
|
2518
|
+
outputSchema: {
|
|
2519
|
+
mode: z.enum(["registry", "live"]),
|
|
2520
|
+
count: z.number(),
|
|
2521
|
+
generatedAt: z.string(),
|
|
2522
|
+
servers: z.array(z.any())
|
|
2523
|
+
}
|
|
1843
2524
|
},
|
|
1844
|
-
async () => {
|
|
2525
|
+
async (params) => {
|
|
1845
2526
|
try {
|
|
1846
2527
|
const serverList = await servers.listServers();
|
|
1847
2528
|
const allNotes = await notes.list();
|
|
1848
|
-
const
|
|
2529
|
+
const registry = Object.entries(serverList).map(([alias, cfg]) => {
|
|
1849
2530
|
const n = allNotes[alias] || {};
|
|
1850
2531
|
return {
|
|
1851
2532
|
alias,
|
|
1852
2533
|
host: cfg.host,
|
|
1853
2534
|
user: cfg.user,
|
|
2535
|
+
port: utils.resolveSshPort(cfg),
|
|
2536
|
+
readonly: cfg.readonly === true || cfg.readOnly === true,
|
|
1854
2537
|
description: n.description || null,
|
|
1855
2538
|
services: n.services || [],
|
|
1856
2539
|
warnings: n.warnings || [],
|
|
2540
|
+
conventions: n.conventions || [],
|
|
1857
2541
|
lastIntervention: n.last_intervention || null
|
|
1858
2542
|
};
|
|
1859
2543
|
});
|
|
1860
|
-
|
|
2544
|
+
|
|
2545
|
+
const liveRequested = params.live === true || Boolean(params.alias);
|
|
2546
|
+
if (!liveRequested) {
|
|
2547
|
+
const payload = { mode: "registry", count: registry.length, generatedAt: new Date().toISOString(), servers: registry };
|
|
2548
|
+
return {
|
|
2549
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
2550
|
+
structuredContent: payload
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
const aliases = await groups.resolveAliases(params.alias || 'all');
|
|
2555
|
+
const registryByAlias = new Map(registry.map(item => [item.alias, item]));
|
|
2556
|
+
const liveServers = await Promise.all(aliases.map(async (alias) => {
|
|
2557
|
+
const base = registryByAlias.get(alias) || { alias };
|
|
2558
|
+
try {
|
|
2559
|
+
const topology = await infraTopology.get(alias, {
|
|
2560
|
+
force: params.force === true,
|
|
2561
|
+
timeoutMs: params.timeoutMs || 25000
|
|
2562
|
+
});
|
|
2563
|
+
return { ok: true, ...base, topology };
|
|
2564
|
+
} catch (error) {
|
|
2565
|
+
return { ok: false, ...base, error: error.message };
|
|
2566
|
+
}
|
|
2567
|
+
}));
|
|
2568
|
+
|
|
2569
|
+
let payload = { mode: "live", count: liveServers.length, generatedAt: new Date().toISOString(), servers: liveServers };
|
|
2570
|
+
if (params.compact !== false) {
|
|
2571
|
+
payload = utils.compactResult(payload, { maxString: 1200, maxArray: 100, dropKeys: ['raw'] });
|
|
2572
|
+
}
|
|
2573
|
+
return {
|
|
2574
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
2575
|
+
structuredContent: payload
|
|
2576
|
+
};
|
|
1861
2577
|
} catch (e) {
|
|
1862
2578
|
const errorPayload = { toolName: "infra_overview", errorCode: "OVERVIEW_ERROR", errorMessage: e.message };
|
|
1863
2579
|
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
@@ -1890,11 +2606,14 @@ server.registerTool(
|
|
|
1890
2606
|
})
|
|
1891
2607
|
},
|
|
1892
2608
|
async (params) => {
|
|
2609
|
+
try { await guardAliasWritable(params.alias, 'tmux_create'); }
|
|
2610
|
+
catch (e) { return { content: [{ type: "text", text: JSON.stringify({ toolName: "tmux_create", errorMessage: e.message }, null, 2) }], isError: true }; }
|
|
1893
2611
|
const tmuxCheck = await checkTmux(params.alias);
|
|
1894
2612
|
if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
|
|
1895
2613
|
const sessionName = params.name || `tmux-${Date.now()}`;
|
|
1896
|
-
const
|
|
1897
|
-
const
|
|
2614
|
+
const qSession = utils.escapeShellArg(sessionName);
|
|
2615
|
+
const startCmd = params.start_cmd ? `tmux send-keys -t ${qSession} -- ${utils.escapeShellArg(params.start_cmd)} Enter` : '';
|
|
2616
|
+
const cmd = `tmux new-session -d -s ${qSession}${startCmd ? ' && ' + startCmd : ''}`;
|
|
1898
2617
|
const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
|
|
1899
2618
|
history.logTask(job);
|
|
1900
2619
|
ssh.executeCommand(job.id);
|
|
@@ -1916,9 +2635,11 @@ server.registerTool(
|
|
|
1916
2635
|
})
|
|
1917
2636
|
},
|
|
1918
2637
|
async (params) => {
|
|
2638
|
+
try { await guardAliasWritable(params.alias, 'tmux_exec'); }
|
|
2639
|
+
catch (e) { return { content: [{ type: "text", text: JSON.stringify({ toolName: "tmux_exec", errorMessage: e.message }, null, 2) }], isError: true }; }
|
|
1919
2640
|
const tmuxCheck = await checkTmux(params.alias);
|
|
1920
2641
|
if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
|
|
1921
|
-
const cmd = `tmux send-keys -t ${params.session}
|
|
2642
|
+
const cmd = `tmux send-keys -t ${utils.escapeShellArg(params.session)} -- ${utils.escapeShellArg(params.cmd)} Enter`;
|
|
1922
2643
|
const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
|
|
1923
2644
|
history.logTask(job);
|
|
1924
2645
|
ssh.executeCommand(job.id);
|
|
@@ -1940,7 +2661,7 @@ server.registerTool(
|
|
|
1940
2661
|
async (params) => {
|
|
1941
2662
|
const tmuxCheck = await checkTmux(params.alias);
|
|
1942
2663
|
if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
|
|
1943
|
-
const cmd = `tmux capture-pane -t ${params.session} -p -S -200`;
|
|
2664
|
+
const cmd = `tmux capture-pane -t ${utils.escapeShellArg(params.session)} -p -S -200`;
|
|
1944
2665
|
const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
|
|
1945
2666
|
history.logTask(job);
|
|
1946
2667
|
ssh.executeCommand(job.id);
|
|
@@ -1983,9 +2704,12 @@ server.registerTool(
|
|
|
1983
2704
|
})
|
|
1984
2705
|
},
|
|
1985
2706
|
async (params) => {
|
|
2707
|
+
try { await guardAliasWritable(params.alias, 'tmux_kill'); }
|
|
2708
|
+
catch (e) { return { content: [{ type: "text", text: JSON.stringify({ toolName: "tmux_kill", errorMessage: e.message }, null, 2) }], isError: true }; }
|
|
1986
2709
|
const tmuxCheck = await checkTmux(params.alias);
|
|
1987
2710
|
if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
|
|
1988
|
-
const
|
|
2711
|
+
const qSession = utils.escapeShellArg(params.session);
|
|
2712
|
+
const cmd = `tmux kill-session -t ${qSession} 2>/dev/null && echo ${utils.escapeShellArg(`Session ${params.session} tuée.`)} || echo 'Session introuvable.'`;
|
|
1989
2713
|
const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
|
|
1990
2714
|
history.logTask(job);
|
|
1991
2715
|
ssh.executeCommand(job.id);
|
|
@@ -2014,6 +2738,8 @@ server.registerTool(
|
|
|
2014
2738
|
},
|
|
2015
2739
|
async (params) => {
|
|
2016
2740
|
try {
|
|
2741
|
+
guardWritable('tunnel_create');
|
|
2742
|
+
if (params.source) await guardAliasWritable(params.source, 'tunnel_create');
|
|
2017
2743
|
const result = await tunnels.create(params, servers);
|
|
2018
2744
|
return { content: [{ type: "text", text: result }] };
|
|
2019
2745
|
} catch (e) {
|
|
@@ -2046,6 +2772,9 @@ server.registerTool(
|
|
|
2046
2772
|
},
|
|
2047
2773
|
async (params) => {
|
|
2048
2774
|
try {
|
|
2775
|
+
guardWritable('tunnel_close');
|
|
2776
|
+
const info = await tunnels.get(params.name);
|
|
2777
|
+
if (info?.source) await guardAliasWritable(info.source, 'tunnel_close');
|
|
2049
2778
|
const result = await tunnels.close(params.name, servers);
|
|
2050
2779
|
return { content: [{ type: "text", text: result }] };
|
|
2051
2780
|
} catch (e) {
|
|
@@ -2064,6 +2793,7 @@ server.registerTool(
|
|
|
2064
2793
|
})
|
|
2065
2794
|
},
|
|
2066
2795
|
async (params) => {
|
|
2796
|
+
guardWritable('tunnel_allowlist_add');
|
|
2067
2797
|
const list = await tunnels.allowlistAdd(params.port);
|
|
2068
2798
|
return { content: [{ type: "text", text: `Port ${params.port} ajouté à l'allowlist.\nPorts autorisés: ${list.join(', ')}` }] };
|
|
2069
2799
|
}
|
|
@@ -2079,6 +2809,7 @@ server.registerTool(
|
|
|
2079
2809
|
})
|
|
2080
2810
|
},
|
|
2081
2811
|
async (params) => {
|
|
2812
|
+
guardWritable('tunnel_allowlist_remove');
|
|
2082
2813
|
const list = await tunnels.allowlistRemove(params.port);
|
|
2083
2814
|
return { content: [{ type: "text", text: `Port ${params.port} retiré de l'allowlist.\nPorts autorisés: ${list.join(', ')}` }] };
|
|
2084
2815
|
}
|