@fkom13/mcp-sftp-orchestrator 6.0.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 +27 -15
- package/CHANGELOG.md +174 -0
- package/README.fr.md +356 -0
- package/README.md +314 -60
- package/apis.js +20 -64
- package/atomicJsonStore.js +117 -0
- package/compareEngine.js +132 -0
- package/config.js +40 -4
- package/diagnose.js +89 -0
- package/diffEngine.js +141 -0
- package/diffFormatter.js +114 -0
- package/fileOps.js +275 -0
- package/fleet.js +170 -0
- package/groups.js +25 -0
- package/guide.js +236 -0
- package/history.js +5 -44
- package/infraTopology.js +336 -0
- package/inventory.js +151 -0
- package/lhm.plugin.json +7 -0
- package/notes.js +3 -0
- package/package.json +11 -26
- package/policies.js +8 -0
- package/projects.js +108 -0
- package/queue.js +180 -125
- package/server.js +2552 -469
- package/servers.js +30 -33
- package/sftp.js +284 -86
- package/shellSessions.js +265 -0
- package/snapshotManager.js +320 -0
- package/sourceAdapter.js +410 -0
- package/ssh.js +184 -86
- package/sshPool.js +161 -148
- package/sshTrust.js +214 -0
- package/toolMetadata.js +76 -0
- package/tunnels.js +243 -0
- package/utils.js +215 -0
- package/workSession.js +139 -0
- package/publish1github.md +0 -216
- package/test_features.js +0 -112
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,159 @@ 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 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';
|
|
31
|
+
import { sourceSchema } from './sourceAdapter.js';
|
|
32
|
+
import infraTopology from './infraTopology.js';
|
|
33
|
+
import { enhanceToolConfig } from './toolMetadata.js';
|
|
34
|
+
|
|
35
|
+
if (DEBUG) {
|
|
36
|
+
console.error("✅ Tous les modules importés");
|
|
37
|
+
console.error("⏳ Initialisation de la queue...");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await queue.init();
|
|
41
|
+
|
|
42
|
+
if (DEBUG) console.error("✅ Queue initialisée");
|
|
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
|
+
|
|
51
|
+
// Vérification des secrets en clair
|
|
52
|
+
try {
|
|
53
|
+
const serverList = await servers.listServers();
|
|
54
|
+
for (const [alias, sc] of Object.entries(serverList)) {
|
|
55
|
+
if (sc.password) {
|
|
56
|
+
console.error(`[SECURITY WARN] Mot de passe en clair détecté pour le serveur '${alias}'. Utilisez une clé SSH ou Vaultwarden.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const apiList = await apis.listApis();
|
|
60
|
+
for (const [alias, ac] of Object.entries(apiList)) {
|
|
61
|
+
if (ac.api_key || ac.htpasswd_pass) {
|
|
62
|
+
console.error(`[SECURITY WARN] Secret en clair détecté pour l'API '${alias}'. Utilisez Vaultwarden.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch (e) { /* silencieux, pas bloquant */ }
|
|
66
|
+
|
|
67
|
+
const ORCH_VERSION = config.version || '11.8.0';
|
|
12
68
|
|
|
13
69
|
const server = new McpServer({
|
|
14
70
|
name: "orchestrator",
|
|
15
|
-
version:
|
|
16
|
-
description: "
|
|
71
|
+
version: ORCH_VERSION,
|
|
72
|
+
description: "IACA Resource / Execution Orchestrator : machines, SSH, fichiers, jobs, transferts, terminaux, snapshots et topologie d'infrastructure."
|
|
17
73
|
});
|
|
18
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
|
+
|
|
127
|
+
if (DEBUG) console.error("✅ Serveur MCP créé");
|
|
128
|
+
|
|
129
|
+
// Restaurer les tunnels locaux au démarrage
|
|
130
|
+
tunnels.restore(servers).then(n => {
|
|
131
|
+
if (n > 0) queue.log('info', `${n} tunnels locaux restaurés.`);
|
|
132
|
+
}).catch(() => {});
|
|
133
|
+
|
|
134
|
+
server.registerTool(
|
|
135
|
+
"system_diagnostics",
|
|
136
|
+
{
|
|
137
|
+
title: "Diagnostic système complet",
|
|
138
|
+
description: "Exécute un diagnostic complet du système MCP.",
|
|
139
|
+
inputSchema: z.object({
|
|
140
|
+
verbose: z.boolean().optional().default(false)
|
|
141
|
+
})
|
|
142
|
+
},
|
|
143
|
+
async (params) => {
|
|
144
|
+
const stats = {
|
|
145
|
+
queue: queue.getStats(),
|
|
146
|
+
pool: ssh.getPoolStats(),
|
|
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
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (params.verbose) {
|
|
156
|
+
stats.logs = queue.getLogs({ limit: 20 });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { content: [{ type: "text", text: JSON.stringify(stats, null, 2) }] };
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
|
|
19
164
|
// --- OUTILS DE GESTION DES SERVEURS ---
|
|
165
|
+
|
|
20
166
|
server.registerTool(
|
|
21
167
|
"server_add",
|
|
22
168
|
{
|
|
@@ -27,13 +173,16 @@ server.registerTool(
|
|
|
27
173
|
host: z.string().describe("Adresse IP ou nom d'hôte du serveur"),
|
|
28
174
|
user: z.string().describe("Nom d'utilisateur pour la connexion"),
|
|
29
175
|
keyPath: z.string().optional().describe("Chemin absolu vers la clé privée SSH."),
|
|
30
|
-
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).")
|
|
31
179
|
}).refine(data => data.keyPath || data.password, {
|
|
32
180
|
message: "Vous devez fournir au moins une méthode d'authentification ('keyPath' ou 'password')."
|
|
33
181
|
})
|
|
34
182
|
},
|
|
35
183
|
async (params) => {
|
|
36
184
|
try {
|
|
185
|
+
guardWritable('server_add');
|
|
37
186
|
const { alias, ...serverConfig } = params;
|
|
38
187
|
const result = await servers.addServer(alias, serverConfig);
|
|
39
188
|
return { content: [{ type: "text", text: result.message }] };
|
|
@@ -53,10 +202,10 @@ server.registerTool(
|
|
|
53
202
|
{
|
|
54
203
|
title: "Lister les alias de serveurs",
|
|
55
204
|
description: "Affiche la liste de tous les alias de serveurs configurés avec leurs détails.",
|
|
56
|
-
inputSchema: {}
|
|
205
|
+
inputSchema: z.object({})
|
|
57
206
|
},
|
|
58
207
|
async () => {
|
|
59
|
-
const serverList = await servers.listServers();
|
|
208
|
+
const serverList = utils.redactSensitiveObject(await servers.listServers());
|
|
60
209
|
return { content: [{ type: "text", text: JSON.stringify(serverList, null, 2) }] };
|
|
61
210
|
}
|
|
62
211
|
);
|
|
@@ -66,12 +215,13 @@ server.registerTool(
|
|
|
66
215
|
{
|
|
67
216
|
title: "Supprimer un alias de serveur",
|
|
68
217
|
description: "Supprime un alias de serveur de la configuration.",
|
|
69
|
-
inputSchema: {
|
|
218
|
+
inputSchema: z.object({
|
|
70
219
|
alias: z.string().describe("Nom de l'alias à supprimer")
|
|
71
|
-
}
|
|
220
|
+
})
|
|
72
221
|
},
|
|
73
222
|
async (params) => {
|
|
74
223
|
try {
|
|
224
|
+
guardWritable('server_remove');
|
|
75
225
|
const result = await servers.removeServer(params.alias);
|
|
76
226
|
return { content: [{ type: "text", text: result.message }] };
|
|
77
227
|
} catch (e) {
|
|
@@ -85,688 +235,2621 @@ server.registerTool(
|
|
|
85
235
|
}
|
|
86
236
|
);
|
|
87
237
|
|
|
88
|
-
// ---
|
|
238
|
+
// --- GROUPES D'ALIAS ---
|
|
239
|
+
|
|
89
240
|
server.registerTool(
|
|
90
|
-
"
|
|
241
|
+
"server_group_list",
|
|
91
242
|
{
|
|
92
|
-
title: "
|
|
93
|
-
description: "
|
|
94
|
-
inputSchema: {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
+
})
|
|
107
262
|
},
|
|
108
263
|
async (params) => {
|
|
109
264
|
try {
|
|
110
|
-
|
|
111
|
-
const result = await
|
|
112
|
-
return { content: [{ type: "text", text: result
|
|
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) }] };
|
|
113
268
|
} catch (e) {
|
|
114
|
-
return { content: [{ type: "text", text:
|
|
269
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "server_group_set", errorMessage: e.message }, null, 2) }], isError: true };
|
|
115
270
|
}
|
|
116
271
|
}
|
|
117
272
|
);
|
|
118
273
|
|
|
119
274
|
server.registerTool(
|
|
120
|
-
"
|
|
275
|
+
"server_group_remove",
|
|
121
276
|
{
|
|
122
|
-
title: "
|
|
123
|
-
description: "
|
|
124
|
-
inputSchema: {
|
|
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
|
+
})
|
|
125
282
|
},
|
|
126
|
-
async () => {
|
|
127
|
-
|
|
128
|
-
|
|
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
|
+
}
|
|
129
291
|
}
|
|
130
292
|
);
|
|
131
293
|
|
|
132
294
|
server.registerTool(
|
|
133
|
-
"
|
|
295
|
+
"fleet_status",
|
|
134
296
|
{
|
|
135
|
-
title: "
|
|
136
|
-
description: "
|
|
137
|
-
inputSchema: {
|
|
138
|
-
alias: z.string().describe("Alias
|
|
139
|
-
|
|
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
|
+
})
|
|
140
303
|
},
|
|
141
304
|
async (params) => {
|
|
142
305
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
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) }] };
|
|
145
315
|
} catch (e) {
|
|
146
|
-
return { content: [{ type: "text", text:
|
|
316
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "fleet_status", errorMessage: e.message }, null, 2) }], isError: true };
|
|
147
317
|
}
|
|
148
318
|
}
|
|
149
319
|
);
|
|
150
320
|
|
|
151
321
|
server.registerTool(
|
|
152
|
-
"
|
|
322
|
+
"infra_audit",
|
|
153
323
|
{
|
|
154
|
-
title: "
|
|
155
|
-
description: "
|
|
156
|
-
inputSchema: {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
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
|
+
})
|
|
160
329
|
},
|
|
161
330
|
async (params) => {
|
|
162
331
|
try {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
);
|
|
182
358
|
|
|
183
|
-
|
|
184
|
-
ssh.executeCommand(job.id);
|
|
185
|
-
const result = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
359
|
+
// --- PROJETS (A) ---
|
|
186
360
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
);
|
|
190
370
|
|
|
191
|
-
|
|
192
|
-
|
|
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));
|
|
193
381
|
} catch (e) {
|
|
194
|
-
return { content: [{ type: "text", text:
|
|
382
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "project_get", errorMessage: e.message }, null, 2) }], isError: true };
|
|
195
383
|
}
|
|
196
384
|
}
|
|
197
385
|
);
|
|
198
386
|
|
|
199
|
-
// --- NOUVEAUX OUTILS DE MONITORING ---
|
|
200
387
|
server.registerTool(
|
|
201
|
-
"
|
|
388
|
+
"project_set",
|
|
202
389
|
{
|
|
203
|
-
title: "
|
|
204
|
-
description: "
|
|
205
|
-
inputSchema: {
|
|
206
|
-
|
|
207
|
-
|
|
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
|
+
})
|
|
208
406
|
},
|
|
209
407
|
async (params) => {
|
|
210
408
|
try {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
cmd: "uptime && free -h && df -h /"
|
|
215
|
-
});
|
|
216
|
-
ssh.executeCommand(job.id);
|
|
217
|
-
const result = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
218
|
-
|
|
219
|
-
if (!result || result.status !== 'completed') {
|
|
220
|
-
throw new Error(result ? result.error : `Timeout de la commande de monitoring pour ${params.alias}`);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const parsedOutput = ssh.parseSystemResources(result.output);
|
|
224
|
-
return { content: [{ type: "text", text: JSON.stringify(parsedOutput, null, 2) }] };
|
|
409
|
+
guardWritable('project_set');
|
|
410
|
+
const { name, ...fields } = params;
|
|
411
|
+
return jsonResult(await projects.set(name, fields));
|
|
225
412
|
} catch (e) {
|
|
226
|
-
|
|
227
|
-
toolName: "get_system_resources",
|
|
228
|
-
errorCode: "MONITORING_ERROR",
|
|
229
|
-
errorMessage: e.message
|
|
230
|
-
};
|
|
231
|
-
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
413
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "project_set", errorMessage: e.message }, null, 2) }], isError: true };
|
|
232
414
|
}
|
|
233
415
|
}
|
|
234
416
|
);
|
|
235
417
|
|
|
236
418
|
server.registerTool(
|
|
237
|
-
"
|
|
419
|
+
"project_remove",
|
|
238
420
|
{
|
|
239
|
-
title: "
|
|
240
|
-
|
|
241
|
-
inputSchema: {
|
|
242
|
-
alias: z.string().describe("Alias du serveur cible.")
|
|
243
|
-
}
|
|
421
|
+
title: "Supprimer un projet du registre",
|
|
422
|
+
inputSchema: z.object({ name: z.string() })
|
|
244
423
|
},
|
|
245
424
|
async (params) => {
|
|
246
425
|
try {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
type: 'ssh',
|
|
250
|
-
alias: params.alias,
|
|
251
|
-
cmd: cmd
|
|
252
|
-
});
|
|
253
|
-
ssh.executeCommand(job.id);
|
|
254
|
-
const result = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
255
|
-
|
|
256
|
-
if (!result || result.status !== 'completed') {
|
|
257
|
-
throw new Error(result ? result.error : `Timeout de la commande de monitoring pour ${params.alias}`);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const parsedOutput = ssh.parseServicesStatus(result.output);
|
|
261
|
-
return { content: [{ type: "text", text: JSON.stringify(parsedOutput, null, 2) }] };
|
|
426
|
+
guardWritable('project_remove');
|
|
427
|
+
return jsonResult(await projects.remove(params.name));
|
|
262
428
|
} catch (e) {
|
|
263
|
-
|
|
264
|
-
toolName: "get_services_status",
|
|
265
|
-
errorCode: "MONITORING_ERROR",
|
|
266
|
-
errorMessage: e.message
|
|
267
|
-
};
|
|
268
|
-
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
429
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "project_remove", errorMessage: e.message }, null, 2) }], isError: true };
|
|
269
430
|
}
|
|
270
431
|
}
|
|
271
432
|
);
|
|
272
433
|
|
|
273
434
|
server.registerTool(
|
|
274
|
-
"
|
|
435
|
+
"project_resolve",
|
|
275
436
|
{
|
|
276
|
-
title: "
|
|
277
|
-
description: "
|
|
278
|
-
inputSchema: {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
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
|
+
})
|
|
282
443
|
},
|
|
283
444
|
async (params) => {
|
|
284
445
|
try {
|
|
285
|
-
|
|
286
|
-
const job = queue.addJob({
|
|
287
|
-
type: 'ssh',
|
|
288
|
-
alias: params.alias,
|
|
289
|
-
cmd: cmd
|
|
290
|
-
});
|
|
291
|
-
ssh.executeCommand(job.id);
|
|
292
|
-
const result = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
293
|
-
|
|
294
|
-
if (!result || result.status !== 'completed') {
|
|
295
|
-
throw new Error(result ? result.error : `Timeout de la commande de monitoring pour ${params.alias}`);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const parsedOutput = ssh.parseApiHealth(result.output);
|
|
299
|
-
return { content: [{ type: "text", text: JSON.stringify(parsedOutput, null, 2) }] };
|
|
446
|
+
return jsonResult(await projects.resolve(params.name, params.role || null));
|
|
300
447
|
} catch (e) {
|
|
301
|
-
|
|
302
|
-
toolName: "check_api_health",
|
|
303
|
-
errorCode: "MONITORING_ERROR",
|
|
304
|
-
errorMessage: e.message
|
|
305
|
-
};
|
|
306
|
-
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
448
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "project_resolve", errorMessage: e.message }, null, 2) }], isError: true };
|
|
307
449
|
}
|
|
308
450
|
}
|
|
309
451
|
);
|
|
310
452
|
|
|
453
|
+
// --- PROJECT DIFF (C) ---
|
|
454
|
+
|
|
311
455
|
server.registerTool(
|
|
312
|
-
"
|
|
456
|
+
"project_diff",
|
|
313
457
|
{
|
|
314
|
-
title: "
|
|
315
|
-
description: "
|
|
316
|
-
inputSchema: {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
})
|
|
320
466
|
},
|
|
321
467
|
async (params) => {
|
|
322
468
|
try {
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
alias: params.alias,
|
|
327
|
-
cmd: cmd
|
|
328
|
-
});
|
|
329
|
-
ssh.executeCommand(job.id);
|
|
330
|
-
const result = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
331
|
-
|
|
332
|
-
if (!result || result.status !== 'completed') {
|
|
333
|
-
throw new Error(result ? result.error : `Timeout de la commande de monitoring pour ${params.alias}`);
|
|
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.");
|
|
334
472
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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)
|
|
343
490
|
};
|
|
344
|
-
|
|
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
|
+
|
|
654
|
+
// --- OUTILS DE SÉCURITÉ / POLICIES ---
|
|
655
|
+
|
|
656
|
+
server.registerTool(
|
|
657
|
+
"policy_blocklist_list",
|
|
658
|
+
{
|
|
659
|
+
title: "Lister les commandes bloquées",
|
|
660
|
+
description: "Affiche la liste des patterns de commandes bloquées par la politique de sécurité.",
|
|
661
|
+
inputSchema: z.object({})
|
|
662
|
+
},
|
|
663
|
+
async () => {
|
|
664
|
+
const policiesData = await policies.list();
|
|
665
|
+
return { content: [{ type: "text", text: JSON.stringify(policiesData, null, 2) }] };
|
|
666
|
+
}
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
server.registerTool(
|
|
670
|
+
"policy_blocklist_add",
|
|
671
|
+
{
|
|
672
|
+
title: "Ajouter une commande à la blocklist",
|
|
673
|
+
description: "Ajoute un pattern de commande à la liste noire. Le pattern peut contenir * comme joker.",
|
|
674
|
+
inputSchema: z.object({
|
|
675
|
+
pattern: z.string().describe("Pattern de commande à bloquer (ex: 'rm -rf /' ou 'wget * | sh')")
|
|
676
|
+
})
|
|
677
|
+
},
|
|
678
|
+
async (params) => {
|
|
679
|
+
const policiesData = await policies.add(params.pattern);
|
|
680
|
+
return { content: [{ type: "text", text: `Pattern '${params.pattern}' ajouté à la blocklist.\n` + JSON.stringify(policiesData, null, 2) }] };
|
|
681
|
+
}
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
server.registerTool(
|
|
685
|
+
"policy_blocklist_remove",
|
|
686
|
+
{
|
|
687
|
+
title: "Retirer une commande de la blocklist",
|
|
688
|
+
description: "Supprime un pattern de commande de la liste noire.",
|
|
689
|
+
inputSchema: z.object({
|
|
690
|
+
pattern: z.string().describe("Pattern à retirer de la blocklist")
|
|
691
|
+
})
|
|
692
|
+
},
|
|
693
|
+
async (params) => {
|
|
694
|
+
const policiesData = await policies.remove(params.pattern);
|
|
695
|
+
return { content: [{ type: "text", text: `Pattern '${params.pattern}' retiré de la blocklist.\n` + JSON.stringify(policiesData, null, 2) }] };
|
|
696
|
+
}
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
// --- OUTILS DE GESTION D'API ---
|
|
700
|
+
server.registerTool(
|
|
701
|
+
"api_add",
|
|
702
|
+
{
|
|
703
|
+
title: "Ajouter une API au catalogue",
|
|
704
|
+
description: "Ajoute ou met à jour une API dans le catalogue de monitoring.",
|
|
705
|
+
inputSchema: z.object({
|
|
706
|
+
alias: z.string().describe("Alias unique pour l'API."),
|
|
707
|
+
url: z.string().url().describe("URL de base de l'API, incluant le port si nécessaire."),
|
|
708
|
+
health_check_endpoint: z.string().optional().describe("Endpoint spécifique pour le test de santé (ex: /health)."),
|
|
709
|
+
health_check_method: z.enum(['GET', 'POST']).optional().default('GET').describe("Méthode HTTP pour le test de santé."),
|
|
710
|
+
auth_method: z.enum(['api_key', 'htpasswd', 'both', 'none']).optional().default('none').describe("Méthode d'authentification."),
|
|
711
|
+
api_key: z.string().optional().describe("Clé API si nécessaire."),
|
|
712
|
+
auth_header_name: z.string().optional().default('Authorization').describe("Nom du header pour la clé API."),
|
|
713
|
+
auth_scheme: z.string().optional().default('Bearer').describe("Schéma d'authentification (ex: Bearer). Mettre à '' si non applicable."),
|
|
714
|
+
htpasswd_user: z.string().optional().describe("Nom d'utilisateur pour l'authentification Basic (htpasswd)."),
|
|
715
|
+
htpasswd_pass: z.string().optional().describe("Mot de passe pour l'authentification Basic (htpasswd)."),
|
|
716
|
+
notes: z.string().optional().describe("Notes additionnelles.")
|
|
717
|
+
})
|
|
718
|
+
},
|
|
719
|
+
async (params) => {
|
|
720
|
+
try {
|
|
721
|
+
guardWritable('api_add');
|
|
722
|
+
const { alias, ...apiConfig } = params;
|
|
723
|
+
const result = await apis.addApi(alias, apiConfig);
|
|
724
|
+
return { content: [{ type: "text", text: result.message }] };
|
|
725
|
+
} catch (e) {
|
|
726
|
+
const errorPayload = {
|
|
727
|
+
toolName: "api_add",
|
|
728
|
+
errorCode: "TOOL_EXECUTION_ERROR",
|
|
729
|
+
errorMessage: e.message
|
|
730
|
+
};
|
|
731
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
server.registerTool(
|
|
737
|
+
"api_list",
|
|
738
|
+
{
|
|
739
|
+
title: "Lister les APIs du catalogue",
|
|
740
|
+
description: "Affiche toutes les APIs configurées dans le catalogue.",
|
|
741
|
+
inputSchema: z.object({})
|
|
742
|
+
},
|
|
743
|
+
async () => {
|
|
744
|
+
// Ne jamais renvoyer api_key / htpasswd_pass en clair aux agents
|
|
745
|
+
const apiList = utils.redactSensitiveObject(await apis.listApis());
|
|
746
|
+
return { content: [{ type: "text", text: JSON.stringify(apiList, null, 2) }] };
|
|
747
|
+
}
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
server.registerTool(
|
|
751
|
+
"api_remove",
|
|
752
|
+
{
|
|
753
|
+
title: "Supprimer une API du catalogue",
|
|
754
|
+
description: "Supprime une API du catalogue en utilisant son alias.",
|
|
755
|
+
inputSchema: z.object({
|
|
756
|
+
alias: z.string().describe("Alias de l'API à supprimer.")
|
|
757
|
+
})
|
|
758
|
+
},
|
|
759
|
+
async (params) => {
|
|
760
|
+
try {
|
|
761
|
+
guardWritable('api_remove');
|
|
762
|
+
const result = await apis.removeApi(params.alias);
|
|
763
|
+
return { content: [{ type: "text", text: result.message }] };
|
|
764
|
+
} catch (e) {
|
|
765
|
+
const errorPayload = {
|
|
766
|
+
toolName: "api_remove",
|
|
767
|
+
errorCode: "TOOL_EXECUTION_ERROR",
|
|
768
|
+
errorMessage: e.message
|
|
769
|
+
};
|
|
770
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
server.registerTool(
|
|
776
|
+
"api_check",
|
|
777
|
+
{
|
|
778
|
+
title: "Vérifier la santé d'une API via son alias",
|
|
779
|
+
description: "Lance un test de santé sur une API du catalogue.",
|
|
780
|
+
inputSchema: z.object({
|
|
781
|
+
alias: z.string().describe("Alias de l'API à tester."),
|
|
782
|
+
server_alias: z.string().describe("Alias du serveur depuis lequel lancer le test.")
|
|
783
|
+
})
|
|
784
|
+
},
|
|
785
|
+
async (params) => {
|
|
786
|
+
try {
|
|
787
|
+
const apiConfig = await apis.getApi(params.alias);
|
|
788
|
+
const endpoint = apiConfig.health_check_endpoint || '';
|
|
789
|
+
const url = `${apiConfig.url}${endpoint}`;
|
|
790
|
+
const method = apiConfig.health_check_method || 'GET';
|
|
791
|
+
|
|
792
|
+
let curlCmd = `curl -X ${method} -o /dev/null -s -w '%{http_code}:%{time_total}'`;
|
|
793
|
+
|
|
794
|
+
// Gérer l'authentification htpasswd
|
|
795
|
+
if ((apiConfig.auth_method === 'htpasswd' || apiConfig.auth_method === 'both') && apiConfig.htpasswd_user && apiConfig.htpasswd_pass) {
|
|
796
|
+
const credentials = `${apiConfig.htpasswd_user}:${apiConfig.htpasswd_pass}`;
|
|
797
|
+
curlCmd += ` -u ${utils.escapeShellArg(credentials)}`;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Gérer l'authentification par clé API
|
|
801
|
+
if ((apiConfig.auth_method === 'api_key' || apiConfig.auth_method === 'both') && apiConfig.api_key) {
|
|
802
|
+
const scheme = apiConfig.auth_scheme ? `${apiConfig.auth_scheme} ` : '';
|
|
803
|
+
const headerValue = `${apiConfig.auth_header_name || 'Authorization'}: ${scheme}${apiConfig.api_key}`;
|
|
804
|
+
curlCmd += ` -H ${utils.escapeShellArg(headerValue)}`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
curlCmd += ` ${utils.escapeShellArg(url)}`;
|
|
808
|
+
|
|
809
|
+
const job = queue.addJob({ type: 'ssh', alias: params.server_alias, cmd: curlCmd });
|
|
810
|
+
ssh.executeCommand(job.id);
|
|
811
|
+
const result = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
812
|
+
|
|
813
|
+
if (!result || result.status !== 'completed') {
|
|
814
|
+
throw new Error(result ? result.error : `Timeout de la commande de monitoring pour ${params.alias}`);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const parsedOutput = ssh.parseApiHealth(result.output);
|
|
818
|
+
return { content: [{ type: "text", text: JSON.stringify(parsedOutput, null, 2) }] };
|
|
819
|
+
} catch (e) {
|
|
820
|
+
const errorPayload = {
|
|
821
|
+
toolName: "api_check",
|
|
822
|
+
errorCode: "TOOL_EXECUTION_ERROR",
|
|
823
|
+
errorMessage: e.message
|
|
824
|
+
};
|
|
825
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
// --- NOUVEAUX OUTILS DE MONITORING ---
|
|
831
|
+
server.registerTool(
|
|
832
|
+
"get_system_resources",
|
|
833
|
+
{
|
|
834
|
+
title: "Obtenir les ressources système d'un VPS",
|
|
835
|
+
description: "Récupère les métriques système vitales (CPU, RAM, Disque) d'un serveur.",
|
|
836
|
+
inputSchema: z.object({
|
|
837
|
+
alias: z.string().describe("Alias du serveur cible.")
|
|
838
|
+
})
|
|
839
|
+
},
|
|
840
|
+
async (params) => {
|
|
841
|
+
try {
|
|
842
|
+
const job = queue.addJob({
|
|
843
|
+
type: 'ssh',
|
|
844
|
+
alias: params.alias,
|
|
845
|
+
cmd: "uptime && free -h && df -h /"
|
|
846
|
+
});
|
|
847
|
+
ssh.executeCommand(job.id);
|
|
848
|
+
const result = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
849
|
+
|
|
850
|
+
if (!result || result.status !== 'completed') {
|
|
851
|
+
throw new Error(result ? result.error : `Timeout de la commande de monitoring pour ${params.alias}`);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const parsedOutput = ssh.parseSystemResources(result.output);
|
|
855
|
+
return { content: [{ type: "text", text: JSON.stringify(parsedOutput, null, 2) }] };
|
|
856
|
+
} catch (e) {
|
|
857
|
+
const errorPayload = {
|
|
858
|
+
toolName: "get_system_resources",
|
|
859
|
+
errorCode: "MONITORING_ERROR",
|
|
860
|
+
errorMessage: e.message
|
|
861
|
+
};
|
|
862
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
);
|
|
866
|
+
|
|
867
|
+
server.registerTool(
|
|
868
|
+
"get_services_status",
|
|
869
|
+
{
|
|
870
|
+
title: "Obtenir le statut des services d'un VPS",
|
|
871
|
+
description: "Récupère le statut de tous les services connus (systemd, Docker, PM2) sur un serveur.",
|
|
872
|
+
inputSchema: z.object({
|
|
873
|
+
alias: z.string().describe("Alias du serveur cible.")
|
|
874
|
+
})
|
|
875
|
+
},
|
|
876
|
+
async (params) => {
|
|
877
|
+
try {
|
|
878
|
+
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";
|
|
879
|
+
const job = queue.addJob({
|
|
880
|
+
type: 'ssh',
|
|
881
|
+
alias: params.alias,
|
|
882
|
+
cmd: cmd
|
|
883
|
+
});
|
|
884
|
+
ssh.executeCommand(job.id);
|
|
885
|
+
const result = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
886
|
+
|
|
887
|
+
if (!result || result.status !== 'completed') {
|
|
888
|
+
throw new Error(result ? result.error : `Timeout de la commande de monitoring pour ${params.alias}`);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const parsedOutput = ssh.parseServicesStatus(result.output);
|
|
892
|
+
return { content: [{ type: "text", text: JSON.stringify(parsedOutput, null, 2) }] };
|
|
893
|
+
} catch (e) {
|
|
894
|
+
const errorPayload = {
|
|
895
|
+
toolName: "get_services_status",
|
|
896
|
+
errorCode: "MONITORING_ERROR",
|
|
897
|
+
errorMessage: e.message
|
|
898
|
+
};
|
|
899
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
server.registerTool(
|
|
905
|
+
"check_api_health",
|
|
906
|
+
{
|
|
907
|
+
title: "Vérifier la santé d'une API",
|
|
908
|
+
description: "Vérifie la disponibilité et le temps de réponse d'un endpoint HTTP/S.",
|
|
909
|
+
inputSchema: z.object({
|
|
910
|
+
alias: z.string().describe("Alias du serveur depuis lequel lancer le test."),
|
|
911
|
+
url: z.string().url().describe("URL complète de l'endpoint à tester.")
|
|
912
|
+
})
|
|
913
|
+
},
|
|
914
|
+
async (params) => {
|
|
915
|
+
try {
|
|
916
|
+
const cmd = `curl -o /dev/null -s -w '%{http_code}:%{time_total}' ${utils.escapeShellArg(params.url)}`;
|
|
917
|
+
const job = queue.addJob({
|
|
918
|
+
type: 'ssh',
|
|
919
|
+
alias: params.alias,
|
|
920
|
+
cmd: cmd
|
|
921
|
+
});
|
|
922
|
+
ssh.executeCommand(job.id);
|
|
923
|
+
const result = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
924
|
+
|
|
925
|
+
if (!result || result.status !== 'completed') {
|
|
926
|
+
throw new Error(result ? result.error : `Timeout de la commande de monitoring pour ${params.alias}`);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const parsedOutput = ssh.parseApiHealth(result.output);
|
|
930
|
+
return { content: [{ type: "text", text: JSON.stringify(parsedOutput, null, 2) }] };
|
|
931
|
+
} catch (e) {
|
|
932
|
+
const errorPayload = {
|
|
933
|
+
toolName: "check_api_health",
|
|
934
|
+
errorCode: "MONITORING_ERROR",
|
|
935
|
+
errorMessage: e.message
|
|
936
|
+
};
|
|
937
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
server.registerTool(
|
|
943
|
+
"get_fail2ban_status",
|
|
944
|
+
{
|
|
945
|
+
title: "Obtenir le statut de Fail2Ban",
|
|
946
|
+
description: "Récupère les informations du service Fail2Ban, pour toutes les jails ou une jail spécifique.",
|
|
947
|
+
inputSchema: z.object({
|
|
948
|
+
alias: z.string().describe("Alias du serveur cible."),
|
|
949
|
+
jail: z.string().optional().describe("Nom d'une jail spécifique à inspecter (ex: sshd). Laissez vide pour un statut général.")
|
|
950
|
+
})
|
|
951
|
+
},
|
|
952
|
+
async (params) => {
|
|
953
|
+
try {
|
|
954
|
+
const cmd = params.jail ? `sudo fail2ban-client status ${utils.escapeShellArg(params.jail)}` : 'sudo fail2ban-client status';
|
|
955
|
+
const job = queue.addJob({
|
|
956
|
+
type: 'ssh',
|
|
957
|
+
alias: params.alias,
|
|
958
|
+
cmd: cmd
|
|
959
|
+
});
|
|
960
|
+
ssh.executeCommand(job.id);
|
|
961
|
+
const result = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
962
|
+
|
|
963
|
+
if (!result || result.status !== 'completed') {
|
|
964
|
+
throw new Error(result ? result.error : `Timeout de la commande de monitoring pour ${params.alias}`);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Pour cette version, nous retournons la sortie brute.
|
|
968
|
+
return { content: [{ type: "text", text: result.output }] };
|
|
969
|
+
} catch (e) {
|
|
970
|
+
const errorPayload = {
|
|
971
|
+
toolName: "get_fail2ban_status",
|
|
972
|
+
errorCode: "MONITORING_ERROR",
|
|
973
|
+
errorMessage: e.message
|
|
974
|
+
};
|
|
975
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
345
976
|
}
|
|
346
977
|
}
|
|
347
978
|
);
|
|
348
979
|
|
|
349
980
|
|
|
981
|
+
// --- LOGIQUE D'ATTENTE HYBRIDE ---
|
|
350
982
|
// --- LOGIQUE D'ATTENTE HYBRIDE ---
|
|
351
983
|
async function waitForJobCompletion(jobId, timeout) {
|
|
352
984
|
return new Promise((resolve) => {
|
|
353
985
|
const startTime = Date.now();
|
|
354
986
|
const interval = setInterval(() => {
|
|
355
987
|
const job = queue.getJob(jobId);
|
|
356
|
-
if (job
|
|
988
|
+
if (!job) {
|
|
989
|
+
clearInterval(interval);
|
|
990
|
+
resolve(null);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
// completed | failed | partial | crashed = terminal (plus d'attente)
|
|
994
|
+
if (utils.isTerminalJobStatus(job.status)) {
|
|
357
995
|
clearInterval(interval);
|
|
358
996
|
resolve(job);
|
|
359
|
-
} else if (Date.now() - startTime > timeout) {
|
|
997
|
+
} else if (timeout > 0 && Date.now() - startTime > timeout) {
|
|
360
998
|
clearInterval(interval);
|
|
361
999
|
resolve(null);
|
|
362
1000
|
}
|
|
363
|
-
}, 200);
|
|
364
|
-
});
|
|
1001
|
+
}, 200);
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function buildAsyncMessage(job, toolType) {
|
|
1006
|
+
const waitCmd = `Utilisez task_wait avec l'ID ${job.id} pour attendre la fin et récupérer le résultat.`;
|
|
1007
|
+
return `Tâche ${toolType} ${job.id} passée en arrière-plan (timeout dépassé). Statut actuel: ${job.status || 'pending'}.\n${waitCmd}`;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// --- EXÉCUTION DE TÂCHES ---
|
|
1011
|
+
server.registerTool(
|
|
1012
|
+
"task_transfer",
|
|
1013
|
+
{
|
|
1014
|
+
title: "Transférer un fichier ou dossier (SFTP)",
|
|
1015
|
+
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).`,
|
|
1016
|
+
inputSchema: z.object({
|
|
1017
|
+
alias: z.string().describe("Alias du serveur cible."),
|
|
1018
|
+
direction: z.enum(['upload', 'download', 'server_to_server']),
|
|
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)."),
|
|
1021
|
+
source_alias: z.string().optional().describe("Alias source (requis si direction='server_to_server')."),
|
|
1022
|
+
force: z.boolean().optional().default(false).describe("Écraser les fichiers existants sans confirmation."),
|
|
1023
|
+
rappel: z.number().optional().describe("Définit un rappel en secondes.")
|
|
1024
|
+
})
|
|
1025
|
+
},
|
|
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
|
+
}
|
|
1035
|
+
const job = queue.addJob({ type: 'sftp', ...params, status: 'pending' });
|
|
1036
|
+
history.logTask(job);
|
|
1037
|
+
sftp.executeTransfer(job.id);
|
|
1038
|
+
|
|
1039
|
+
const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
1040
|
+
if (finalJob) {
|
|
1041
|
+
return {
|
|
1042
|
+
content: [{
|
|
1043
|
+
type: "text", text: `Résultat direct (tâche ${finalJob.id}):
|
|
1044
|
+
${JSON.stringify(finalJob, null, 2)}`
|
|
1045
|
+
}]
|
|
1046
|
+
};
|
|
1047
|
+
} else {
|
|
1048
|
+
return { content: [{ type: "text", text: buildAsyncMessage(job, 'de transfert') }] };
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
);
|
|
1052
|
+
|
|
1053
|
+
server.registerTool(
|
|
1054
|
+
"task_exec",
|
|
1055
|
+
{
|
|
1056
|
+
title: "Exécuter une commande à distance (SSH) — multi-serveur supporté",
|
|
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.`,
|
|
1058
|
+
inputSchema: z.object({
|
|
1059
|
+
alias: z.union([z.string(), z.array(z.string())]).describe("Alias, tableau, 'all', 'group:oci' ou nom de groupe."),
|
|
1060
|
+
cmd: z.string().describe("La commande complète à exécuter."),
|
|
1061
|
+
timeout: z.number().optional().describe("Timeout en secondes. 0 = pas de limite. Défaut: 600s (10 min)."),
|
|
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."),
|
|
1065
|
+
rappel: z.number().optional().describe("Définit un rappel en secondes.")
|
|
1066
|
+
})
|
|
1067
|
+
},
|
|
1068
|
+
async (params) => {
|
|
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);
|
|
1079
|
+
|
|
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
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
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); });
|
|
1114
|
+
|
|
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');
|
|
1121
|
+
|
|
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
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
);
|
|
1128
|
+
|
|
1129
|
+
// --- OUTILS DE SUIVI ET HISTORIQUE ---
|
|
1130
|
+
function formatJobForDisplay(job) {
|
|
1131
|
+
const displayJob = { ...job };
|
|
1132
|
+
if (job.status === 'running' && job.reminderAt && new Date() > new Date(job.reminderAt)) {
|
|
1133
|
+
displayJob.reminder = "ATTENTION: Le temps de rappel est écoulé. La tâche est peut-être terminée ou bloquée.";
|
|
1134
|
+
}
|
|
1135
|
+
return displayJob;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
server.registerTool(
|
|
1140
|
+
"task_queue",
|
|
1141
|
+
{
|
|
1142
|
+
title: "Voir la file d'attente des tâches",
|
|
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
|
+
})
|
|
1149
|
+
},
|
|
1150
|
+
async (params) => {
|
|
1151
|
+
const queueState = queue.getQueue();
|
|
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);
|
|
1157
|
+
}
|
|
1158
|
+
);
|
|
1159
|
+
|
|
1160
|
+
server.registerTool(
|
|
1161
|
+
"task_status",
|
|
1162
|
+
{
|
|
1163
|
+
title: "Consulter une tâche par son ID",
|
|
1164
|
+
description: "Récupère les détails d'une seule tâche, avec un rappel si nécessaire.",
|
|
1165
|
+
inputSchema: z.object({
|
|
1166
|
+
id: z.string().describe("L'ID de la tâche à consulter.")
|
|
1167
|
+
})
|
|
1168
|
+
},
|
|
1169
|
+
async (params) => {
|
|
1170
|
+
const job = queue.getJob(params.id);
|
|
1171
|
+
if (!job) return { content: [{ type: "text", text: `ERREUR: Tâche '${params.id}' introuvable.` }], isError: true };
|
|
1172
|
+
|
|
1173
|
+
const displayJob = formatJobForDisplay(job);
|
|
1174
|
+
return { content: [{ type: "text", text: JSON.stringify(displayJob, null, 2) }] };
|
|
1175
|
+
}
|
|
1176
|
+
);
|
|
1177
|
+
|
|
1178
|
+
server.registerTool(
|
|
1179
|
+
"task_history",
|
|
1180
|
+
{
|
|
1181
|
+
title: "Consulter l'historique des tâches",
|
|
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.",
|
|
1183
|
+
inputSchema: z.object({
|
|
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.")
|
|
1188
|
+
})
|
|
1189
|
+
},
|
|
1190
|
+
async (params) => {
|
|
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);
|
|
1194
|
+
}
|
|
1195
|
+
);
|
|
1196
|
+
|
|
1197
|
+
// --- NOUVEAUX OUTILS POUR LES FONCTIONNALITÉS AVANCÉES ---
|
|
1198
|
+
|
|
1199
|
+
server.registerTool(
|
|
1200
|
+
"task_transfer_multi",
|
|
1201
|
+
{
|
|
1202
|
+
title: "Transférer plusieurs fichiers/dossiers (SFTP)",
|
|
1203
|
+
description: "Lance des transferts SFTP multiples avec support de patterns glob (*, ?, []).",
|
|
1204
|
+
inputSchema: z.object({
|
|
1205
|
+
alias: z.string().describe("Alias du serveur cible."),
|
|
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)."),
|
|
1208
|
+
files: z.array(z.object({
|
|
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"),
|
|
1213
|
+
force: z.boolean().optional().default(false).describe("Écraser les fichiers existants sans confirmation."),
|
|
1214
|
+
rappel: z.number().optional().describe("Définit un rappel en secondes.")
|
|
1215
|
+
})
|
|
1216
|
+
},
|
|
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
|
+
}
|
|
1227
|
+
const job = queue.addJob({
|
|
1228
|
+
type: 'sftp',
|
|
1229
|
+
...params,
|
|
1230
|
+
status: 'pending',
|
|
1231
|
+
files: params.files
|
|
1232
|
+
});
|
|
1233
|
+
history.logTask(job);
|
|
1234
|
+
sftp.executeMultiTransfer(job.id);
|
|
1235
|
+
|
|
1236
|
+
const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
1237
|
+
if (finalJob) {
|
|
1238
|
+
return { content: [{ type: "text", text: `Résultat transferts multiples (tâche ${finalJob.id}):\n${JSON.stringify(finalJob, null, 2)}` }] };
|
|
1239
|
+
} else {
|
|
1240
|
+
return { content: [{ type: "text", text: buildAsyncMessage(job, 'de transferts multiples') }] };
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
);
|
|
1244
|
+
|
|
1245
|
+
server.registerTool(
|
|
1246
|
+
"task_exec_interactive",
|
|
1247
|
+
{
|
|
1248
|
+
title: "Exécuter une commande interactive (SSH)",
|
|
1249
|
+
description: "Exécute une commande SSH avec gestion des prompts interactifs (yes/no, passwords, etc.).",
|
|
1250
|
+
inputSchema: z.object({
|
|
1251
|
+
alias: z.string().describe("Alias du serveur cible."),
|
|
1252
|
+
cmd: z.string().describe("La commande à exécuter."),
|
|
1253
|
+
interactive: z.boolean().optional().default(true).describe("Mode interactif."),
|
|
1254
|
+
autoRespond: z.boolean().optional().default(true).describe("Répondre automatiquement aux prompts standards."),
|
|
1255
|
+
responses: z.record(z.string()).optional().describe("Réponses personnalisées aux prompts (clé: pattern, valeur: réponse)."),
|
|
1256
|
+
timeout: z.number().optional().describe("Timeout en secondes. 0 = pas de limite. Défaut: 300s (5 min)."),
|
|
1257
|
+
rappel: z.number().optional().describe("Définit un rappel en secondes.")
|
|
1258
|
+
})
|
|
1259
|
+
},
|
|
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 }; }
|
|
1263
|
+
const job = queue.addJob({
|
|
1264
|
+
type: 'ssh',
|
|
1265
|
+
...params,
|
|
1266
|
+
status: 'pending',
|
|
1267
|
+
interactive: params.interactive,
|
|
1268
|
+
autoRespond: params.autoRespond
|
|
1269
|
+
});
|
|
1270
|
+
history.logTask(job);
|
|
1271
|
+
ssh.executeCommand(job.id);
|
|
1272
|
+
|
|
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);
|
|
1277
|
+
if (finalJob) {
|
|
1278
|
+
return { content: [{ type: "text", text: `Résultat commande interactive (tâche ${finalJob.id}):\n${finalJob.output || JSON.stringify(finalJob, null, 2)}` }] };
|
|
1279
|
+
} else {
|
|
1280
|
+
return { content: [{ type: "text", text: buildAsyncMessage(job, 'interactive') }] };
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
);
|
|
1284
|
+
|
|
1285
|
+
server.registerTool(
|
|
1286
|
+
"task_exec_sequence",
|
|
1287
|
+
{
|
|
1288
|
+
title: "Exécuter une séquence de commandes (SSH)",
|
|
1289
|
+
description: "Exécute plusieurs commandes SSH en séquence sur le même serveur.",
|
|
1290
|
+
inputSchema: z.object({
|
|
1291
|
+
alias: z.string().describe("Alias du serveur cible."),
|
|
1292
|
+
commands: z.array(z.union([
|
|
1293
|
+
z.string(),
|
|
1294
|
+
z.object({
|
|
1295
|
+
command: z.string(),
|
|
1296
|
+
timeout: z.number().optional(),
|
|
1297
|
+
continueOnError: z.boolean().optional()
|
|
1298
|
+
})
|
|
1299
|
+
])).min(1).describe("Liste des commandes à exécuter en séquence (minimum 1)."),
|
|
1300
|
+
continueOnError: z.boolean().optional().default(false).describe("Continuer même si une commande échoue."),
|
|
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."),
|
|
1303
|
+
rappel: z.number().optional().describe("Définit un rappel en secondes.")
|
|
1304
|
+
})
|
|
1305
|
+
},
|
|
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 }; }
|
|
1309
|
+
const job = queue.addJob({
|
|
1310
|
+
type: 'ssh_sequence',
|
|
1311
|
+
...params,
|
|
1312
|
+
skip_policy: params.skip_policy,
|
|
1313
|
+
status: 'pending'
|
|
1314
|
+
});
|
|
1315
|
+
history.logTask(job);
|
|
1316
|
+
ssh.executeCommandSequence(job.id);
|
|
1317
|
+
|
|
1318
|
+
const seqTimeout = params.timeout === 0 ? 0 : (params.timeout ? params.timeout * 1000 : config.syncTimeout * params.commands.length);
|
|
1319
|
+
const finalJob = await waitForJobCompletion(job.id, seqTimeout);
|
|
1320
|
+
if (finalJob) {
|
|
1321
|
+
return { content: [{ type: "text", text: `Résultat séquence (tâche ${finalJob.id}):\n${JSON.stringify(finalJob, null, 2)}` }] };
|
|
1322
|
+
} else {
|
|
1323
|
+
return { content: [{ type: "text", text: buildAsyncMessage(job, 'de séquence') }] };
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
);
|
|
1327
|
+
|
|
1328
|
+
server.registerTool(
|
|
1329
|
+
"pool_stats",
|
|
1330
|
+
{
|
|
1331
|
+
title: "Statistiques du pool de connexions SSH",
|
|
1332
|
+
description: "Affiche les statistiques du pool de connexions SSH persistantes.",
|
|
1333
|
+
inputSchema: z.object({})
|
|
1334
|
+
},
|
|
1335
|
+
async () => {
|
|
1336
|
+
const stats = ssh.getPoolStats();
|
|
1337
|
+
return { content: [{ type: "text", text: `Pool de connexions SSH:\n${JSON.stringify(stats, null, 2)}` }] };
|
|
1338
|
+
}
|
|
1339
|
+
);
|
|
1340
|
+
|
|
1341
|
+
server.registerTool(
|
|
1342
|
+
"queue_stats",
|
|
1343
|
+
{
|
|
1344
|
+
title: "Statistiques de la queue",
|
|
1345
|
+
description: "Affiche les statistiques détaillées de la queue de tâches.",
|
|
1346
|
+
inputSchema: z.object({})
|
|
1347
|
+
},
|
|
1348
|
+
async () => {
|
|
1349
|
+
const stats = queue.getStats();
|
|
1350
|
+
const crashed = queue.getCrashedJobs();
|
|
1351
|
+
const result = {
|
|
1352
|
+
stats,
|
|
1353
|
+
crashedJobs: crashed.length,
|
|
1354
|
+
canRetry: crashed.map(j => ({ id: j.id, type: j.type, crashedAt: j.crashedAt }))
|
|
1355
|
+
};
|
|
1356
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1357
|
+
}
|
|
1358
|
+
);
|
|
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
|
+
|
|
1366
|
+
server.registerTool(
|
|
1367
|
+
"task_retry",
|
|
1368
|
+
{
|
|
1369
|
+
title: "Réessayer une tâche échouée",
|
|
1370
|
+
description: "Relance une tâche qui a échoué ou crashé.",
|
|
1371
|
+
inputSchema: z.object({
|
|
1372
|
+
id: z.string().describe("L'ID de la tâche à réessayer.")
|
|
1373
|
+
})
|
|
1374
|
+
},
|
|
1375
|
+
async (params) => {
|
|
1376
|
+
try {
|
|
1377
|
+
guardWritable('task_retry');
|
|
1378
|
+
const originalJob = queue.getJob(params.id);
|
|
1379
|
+
await guardRetryJobWritable(originalJob, 'task_retry');
|
|
1380
|
+
const newJob = await queue.retryJob(params.id);
|
|
1381
|
+
dispatchJob(newJob);
|
|
1382
|
+
return { content: [{ type: "text", text: `Tâche ${params.id} relancée avec le nouvel ID: ${newJob.id}` }] };
|
|
1383
|
+
} catch (e) {
|
|
1384
|
+
const errorPayload = {
|
|
1385
|
+
toolName: "task_retry",
|
|
1386
|
+
errorCode: "TOOL_EXECUTION_ERROR",
|
|
1387
|
+
errorMessage: e.message
|
|
1388
|
+
};
|
|
1389
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
);
|
|
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
|
+
|
|
1470
|
+
server.registerTool(
|
|
1471
|
+
"task_logs",
|
|
1472
|
+
{
|
|
1473
|
+
title: "Consulter les logs système",
|
|
1474
|
+
description: "Affiche les logs du système MCP.",
|
|
1475
|
+
inputSchema: z.object({
|
|
1476
|
+
level: z.enum(['error', 'warn', 'info', 'debug']).optional().describe("Filtrer par niveau de log."),
|
|
1477
|
+
search: z.string().optional().describe("Rechercher dans les messages."),
|
|
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.")
|
|
1480
|
+
})
|
|
1481
|
+
},
|
|
1482
|
+
async (params) => {
|
|
1483
|
+
const logs = queue.getLogs({
|
|
1484
|
+
level: params.level,
|
|
1485
|
+
search: params.search
|
|
1486
|
+
}).slice(-params.limit);
|
|
1487
|
+
return jsonResult({ logs, count: logs.length }, params);
|
|
1488
|
+
}
|
|
1489
|
+
);
|
|
1490
|
+
|
|
1491
|
+
// --- NOUVEAUX OUTILS DE LOGS (v8) ---
|
|
1492
|
+
|
|
1493
|
+
server.registerTool(
|
|
1494
|
+
"get_pm2_logs",
|
|
1495
|
+
{
|
|
1496
|
+
title: "Récupérer les logs PM2",
|
|
1497
|
+
description: "Raccourci pour récupérer les logs PM2 d'une application spécifique ou de toutes les apps.",
|
|
1498
|
+
inputSchema: z.object({
|
|
1499
|
+
alias: z.string().describe("Alias du serveur cible."),
|
|
1500
|
+
app: z.string().optional().describe("Nom de l'application PM2 (optionnel, toutes par défaut)."),
|
|
1501
|
+
lines: z.number().int().min(1).max(100000).optional().default(100).describe("Nombre de lignes à récupérer (1..100000)."),
|
|
1502
|
+
errors: z.boolean().optional().default(false).describe("Récupérer uniquement les erreurs (stderr).")
|
|
1503
|
+
})
|
|
1504
|
+
},
|
|
1505
|
+
async (params) => {
|
|
1506
|
+
let cmd = 'pm2 logs';
|
|
1507
|
+
if (params.app) cmd += ` ${utils.escapeShellArg(params.app)}`;
|
|
1508
|
+
if (params.errors) cmd += ' --err';
|
|
1509
|
+
cmd += ` --lines ${params.lines} --nostream`;
|
|
1510
|
+
|
|
1511
|
+
const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd: cmd, streaming: false });
|
|
1512
|
+
history.logTask(job);
|
|
1513
|
+
ssh.executeCommand(job.id);
|
|
1514
|
+
|
|
1515
|
+
const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
1516
|
+
if (finalJob) {
|
|
1517
|
+
return { content: [{ type: "text", text: `📋 Logs PM2${params.app ? ` (${params.app})` : ''} - ${finalJob.lineCount || 0} lignes:\n\n${finalJob.output || '(vide)'}` }] };
|
|
1518
|
+
} else {
|
|
1519
|
+
return { content: [{ type: "text", text: buildAsyncMessage(job, 'PM2 logs') }] };
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
);
|
|
1523
|
+
|
|
1524
|
+
server.registerTool(
|
|
1525
|
+
"get_docker_logs",
|
|
1526
|
+
{
|
|
1527
|
+
title: "Récupérer les logs Docker",
|
|
1528
|
+
inputSchema: z.object({
|
|
1529
|
+
alias: z.string().describe("Alias du serveur cible."),
|
|
1530
|
+
container: z.string().describe("Nom ou ID du container Docker."),
|
|
1531
|
+
lines: z.number().int().min(1).max(100000).optional().default(100).describe("Nombre de lignes à récupérer (1..100000)."),
|
|
1532
|
+
since: z.string().optional().describe("Logs depuis (ex: '5m', '1h', '2024-01-01')."),
|
|
1533
|
+
timestamps: z.boolean().optional().default(false).describe("Afficher les timestamps.")
|
|
1534
|
+
})
|
|
1535
|
+
},
|
|
1536
|
+
async (params) => {
|
|
1537
|
+
let cmd = `docker logs --tail ${params.lines}`;
|
|
1538
|
+
if (params.since) cmd += ` --since ${utils.escapeShellArg(params.since)}`;
|
|
1539
|
+
if (params.timestamps) cmd += ' --timestamps';
|
|
1540
|
+
cmd += ` ${utils.escapeShellArg(params.container)}`;
|
|
1541
|
+
|
|
1542
|
+
const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd: cmd, streaming: false });
|
|
1543
|
+
history.logTask(job);
|
|
1544
|
+
ssh.executeCommand(job.id);
|
|
1545
|
+
|
|
1546
|
+
const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
1547
|
+
if (finalJob) {
|
|
1548
|
+
return { content: [{ type: "text", text: `🐳 Logs Docker (${params.container}) - ${finalJob.lineCount || 0} lignes:\n\n${finalJob.output || '(vide)'}` }] };
|
|
1549
|
+
} else {
|
|
1550
|
+
return { content: [{ type: "text", text: buildAsyncMessage(job, 'Docker logs') }] };
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
);
|
|
1554
|
+
|
|
1555
|
+
server.registerTool(
|
|
1556
|
+
"tail_file",
|
|
1557
|
+
{
|
|
1558
|
+
title: "Afficher les dernières lignes d'un fichier",
|
|
1559
|
+
description: "Équivalent de tail -n pour afficher les dernières lignes d'un fichier distant.",
|
|
1560
|
+
inputSchema: z.object({
|
|
1561
|
+
alias: z.string().describe("Alias du serveur cible."),
|
|
1562
|
+
filepath: z.string().describe("Chemin absolu du fichier à lire."),
|
|
1563
|
+
lines: z.number().int().min(1).max(100000).optional().default(50).describe("Nombre de lignes à afficher (1..100000).")
|
|
1564
|
+
})
|
|
1565
|
+
},
|
|
1566
|
+
async (params) => {
|
|
1567
|
+
const cmd = `tail -n ${params.lines} ${utils.escapeShellArg(params.filepath)}`;
|
|
1568
|
+
const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd: cmd, streaming: false });
|
|
1569
|
+
history.logTask(job);
|
|
1570
|
+
ssh.executeCommand(job.id);
|
|
1571
|
+
|
|
1572
|
+
const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
1573
|
+
if (finalJob) {
|
|
1574
|
+
return { content: [{ type: "text", text: `📄 Contenu de ${params.filepath} (${finalJob.lineCount || 0} lignes):\n\n${finalJob.output || '(vide)'}` }] };
|
|
1575
|
+
} else {
|
|
1576
|
+
return { content: [{ type: "text", text: buildAsyncMessage(job, 'tail') }] };
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
);
|
|
1580
|
+
|
|
1581
|
+
// --- OUTIL D'AIDE / GUIDE ---
|
|
1582
|
+
|
|
1583
|
+
server.registerTool(
|
|
1584
|
+
"help",
|
|
1585
|
+
{
|
|
1586
|
+
title: "Guide et documentation des outils",
|
|
1587
|
+
description: "Affiche la documentation complète d'un outil ou la liste de tous les outils disponibles.",
|
|
1588
|
+
inputSchema: z.object({
|
|
1589
|
+
tool: z.string().optional().describe("Nom de l'outil à documenter. Laissez vide pour la liste complète.")
|
|
1590
|
+
})
|
|
1591
|
+
},
|
|
1592
|
+
async (params) => {
|
|
1593
|
+
// Schémas des paramètres pour les outils clés
|
|
1594
|
+
const TOOL_SCHEMAS = {
|
|
1595
|
+
policy_blocklist_list: `Paramètres: aucun`,
|
|
1596
|
+
|
|
1597
|
+
policy_blocklist_add: `Paramètres:
|
|
1598
|
+
pattern (string, requis) — Pattern de commande à bloquer (ex: "rm -rf /" ou "wget * | sh")`,
|
|
1599
|
+
|
|
1600
|
+
policy_blocklist_remove: `Paramètres:
|
|
1601
|
+
pattern (string, requis) — Pattern à retirer de la blocklist`,
|
|
1602
|
+
|
|
1603
|
+
tmux_create: `Paramètres:
|
|
1604
|
+
alias (string, requis) — Serveur cible
|
|
1605
|
+
name (string, opt) — Nom de session (auto-généré si omis)
|
|
1606
|
+
start_cmd (string, opt) — Commande initiale à lancer`,
|
|
1607
|
+
|
|
1608
|
+
tmux_exec: `Paramètres:
|
|
1609
|
+
alias (string, requis) — Serveur cible
|
|
1610
|
+
session (string, requis) — Nom de la session tmux
|
|
1611
|
+
cmd (string, requis) — Commande à exécuter`,
|
|
1612
|
+
|
|
1613
|
+
tmux_read: `Paramètres:
|
|
1614
|
+
alias (string, requis) — Serveur cible
|
|
1615
|
+
session (string, requis) — Nom de la session`,
|
|
1616
|
+
|
|
1617
|
+
tmux_list: `Paramètres:
|
|
1618
|
+
alias (string, requis) — Serveur cible`,
|
|
1619
|
+
|
|
1620
|
+
tmux_kill: `Paramètres:
|
|
1621
|
+
alias (string, requis) — Serveur cible
|
|
1622
|
+
session (string, requis) — Nom de la session à tuer`,
|
|
1623
|
+
|
|
1624
|
+
server_add: `Paramètres:
|
|
1625
|
+
alias (string, requis) — Nom unique du serveur (ex: vps_production)
|
|
1626
|
+
host (string, requis) — IP ou nom d'hôte
|
|
1627
|
+
user (string, requis) — Utilisateur SSH
|
|
1628
|
+
keyPath (string, optionnel) — Chemin absolu vers clé privée SSH
|
|
1629
|
+
password (string, optionnel) — Mot de passe SSH
|
|
1630
|
+
→ Fournir keyPath OU password (au moins un des deux).`,
|
|
1631
|
+
|
|
1632
|
+
api_add: `Paramètres:
|
|
1633
|
+
alias (string, requis) — Nom unique
|
|
1634
|
+
url (string, requis) — URL de base (ex: https://api.example.com)
|
|
1635
|
+
health_check_endpoint (string, opt) — Path de health (défaut: /)
|
|
1636
|
+
health_check_method (GET|POST, opt) — Méthode HTTP (défaut: GET)
|
|
1637
|
+
auth_method (none|api_key|htpasswd|both, opt)
|
|
1638
|
+
api_key (string, opt) — Clé API
|
|
1639
|
+
auth_header_name (string, opt) — Header pour la clé (défaut: Authorization)
|
|
1640
|
+
auth_scheme (string, opt) — Schéma d'auth (défaut: Bearer)
|
|
1641
|
+
htpasswd_user (string, opt) — Utilisateur Basic Auth
|
|
1642
|
+
htpasswd_pass (string, opt) — Mot de passe Basic Auth
|
|
1643
|
+
notes (string, opt) — Notes`,
|
|
1644
|
+
|
|
1645
|
+
task_exec: `Paramètres:
|
|
1646
|
+
alias (string|string[], requis) — Alias, tableau d'alias, ou "all"
|
|
1647
|
+
cmd (string, requis) — Commande à exécuter
|
|
1648
|
+
timeout (number, opt) — Timeout en secondes (0=infini, défaut: 600)
|
|
1649
|
+
skip_policy (bool, opt) — Ignorer la blocklist (défaut: false)
|
|
1650
|
+
rappel (number, opt) — Rappel en secondes`,
|
|
1651
|
+
|
|
1652
|
+
file_read: `Paramètres:
|
|
1653
|
+
source (object, requis) — {type:'local'|'remote', path, alias?, label?}
|
|
1654
|
+
encoding (utf8|base64, opt) — Défaut: utf8
|
|
1655
|
+
offset (int, opt) — Ligne de départ (1-indexée)
|
|
1656
|
+
limit (int, opt) — Nombre de lignes`,
|
|
1657
|
+
|
|
1658
|
+
file_edit: `Paramètres:
|
|
1659
|
+
source (object, requis) — {type, path, alias?, label?}
|
|
1660
|
+
oldString (string, opt*) — Texte à remplacer (mode chirurgical)
|
|
1661
|
+
newString (string, opt*) — Texte de remplacement
|
|
1662
|
+
replaceAll (bool, opt) — Remplacer toutes les occurrences
|
|
1663
|
+
newContent (string, opt*) — Contenu complet (mode complet)
|
|
1664
|
+
expectedHash (string, opt) — Hash SHA-256 (anti-écrasement)
|
|
1665
|
+
dryRun (bool, opt) — Prévisualiser sans écrire
|
|
1666
|
+
backup (bool, opt) — Snapshot avant modif
|
|
1667
|
+
→ (*) Soit oldString+newString, soit newContent.`,
|
|
1668
|
+
|
|
1669
|
+
file_write: `Paramètres:
|
|
1670
|
+
source (object, requis) — {type, path, alias?, label?}
|
|
1671
|
+
content (string, requis) — Contenu à écrire
|
|
1672
|
+
encoding (utf8|base64, opt) — Défaut: utf8
|
|
1673
|
+
createDirs (bool, opt) — Créer les dossiers parents
|
|
1674
|
+
dryRun (bool, opt) — Prévisualiser sans écrire
|
|
1675
|
+
backup (bool, opt) — Snapshot avant écrasement`,
|
|
1676
|
+
|
|
1677
|
+
task_transfer: `Paramètres:
|
|
1678
|
+
alias (string, requis) — Serveur cible
|
|
1679
|
+
direction (upload|download, requis)
|
|
1680
|
+
local (string, requis) — Chemin local absolu
|
|
1681
|
+
remote (string, requis) — Chemin distant absolu
|
|
1682
|
+
force (bool, opt) — Écraser sans confirmation
|
|
1683
|
+
rappel (number, opt) — Rappel en secondes`,
|
|
1684
|
+
|
|
1685
|
+
snapshot_create: `Paramètres:
|
|
1686
|
+
source (object, requis) — {type:'local'|'remote', alias?}
|
|
1687
|
+
paths (string[], req) — Chemins à capturer
|
|
1688
|
+
tag (string, opt) — Étiquette (ex: 'before-fix')
|
|
1689
|
+
message (string, opt) — Description
|
|
1690
|
+
recursive (bool, opt) — Parcours récursif (défaut: true)
|
|
1691
|
+
ignorePatterns (string[], opt) — Motifs glob à exclure`,
|
|
1692
|
+
|
|
1693
|
+
diff_files: `Paramètres:
|
|
1694
|
+
source1 (object, requis) — {type, path, alias?, label?}
|
|
1695
|
+
source2 (object, requis) — {type, path, alias?, label?}`,
|
|
1696
|
+
|
|
1697
|
+
compare_all_sources: `Paramètres:
|
|
1698
|
+
sources (object[], requis) — [{type, path, alias?, label?}, ...] (min 2)
|
|
1699
|
+
includeContent (bool, opt) — Inclure le contenu complet
|
|
1700
|
+
includeDiff (bool, opt) — Générer le diff (défaut: true)`,
|
|
1701
|
+
|
|
1702
|
+
tunnel_create: `Paramètres:
|
|
1703
|
+
name (string, requis) — Nom unique (ex: 'crm-dev')
|
|
1704
|
+
type (local|remote|socks) — Type de tunnel
|
|
1705
|
+
listen_port (number, requis) — Port d'écoute (ex: 8080)
|
|
1706
|
+
target (string, opt) — Cible host:port, requis pour local/remote
|
|
1707
|
+
via (string, requis) — Alias du serveur de transit SSH
|
|
1708
|
+
source (string, opt) — null=local, ou alias serveur`,
|
|
1709
|
+
|
|
1710
|
+
tunnel_list: `Paramètres: aucun`,
|
|
1711
|
+
|
|
1712
|
+
tunnel_close: `Paramètres:
|
|
1713
|
+
name (string, requis) — Nom du tunnel à fermer`,
|
|
1714
|
+
|
|
1715
|
+
tunnel_allowlist_add: `Paramètres:
|
|
1716
|
+
port (number, requis) — Port à autoriser`,
|
|
1717
|
+
|
|
1718
|
+
tunnel_allowlist_remove: `Paramètres:
|
|
1719
|
+
port (number, requis) — Port à retirer`
|
|
1720
|
+
};
|
|
1721
|
+
|
|
1722
|
+
const allTools = [
|
|
1723
|
+
{ name: "help", desc: "Guide et documentation des outils (celui-ci)." },
|
|
1724
|
+
{ name: "system_diagnostics", desc: "Diagnostic complet du système MCP (queue, pool, serveurs, APIs)." },
|
|
1725
|
+
{ name: "server_add", desc: "Ajouter/modifier un alias de serveur SSH." },
|
|
1726
|
+
{ name: "server_list", desc: "Lister tous les serveurs configurés." },
|
|
1727
|
+
{ name: "server_remove", desc: "Supprimer un alias de serveur." },
|
|
1728
|
+
{ name: "api_add", desc: "Ajouter une API au catalogue de monitoring." },
|
|
1729
|
+
{ name: "api_list", desc: "Lister toutes les APIs du catalogue." },
|
|
1730
|
+
{ name: "api_remove", desc: "Supprimer une API du catalogue." },
|
|
1731
|
+
{ name: "api_check", desc: "Test de santé d'une API via son alias (SSH curl)." },
|
|
1732
|
+
{ name: "get_system_resources", desc: "Métriques CPU, RAM, Disque d'un serveur." },
|
|
1733
|
+
{ name: "get_services_status", desc: "Statut des services systemd, Docker, PM2." },
|
|
1734
|
+
{ name: "check_api_health", desc: "Test HTTP direct sur une URL." },
|
|
1735
|
+
{ name: "get_fail2ban_status", desc: "Statut Fail2Ban (toutes les jails ou une spécifique)." },
|
|
1736
|
+
{ name: "task_exec", desc: "Exécuter une commande SSH. Timeout paramétrable (0=infini)." },
|
|
1737
|
+
{ name: "task_transfer", desc: "Transfert SFTP fichier/dossier. server_to_server: transfert direct entre deux serveurs distants." },
|
|
1738
|
+
{ name: "task_exec_interactive", desc: "SSH avec prompts interactifs (yes/no, menus). Supporte regex dans responses." },
|
|
1739
|
+
{ name: "task_exec_sequence", desc: "Séquence de commandes SSH sur le même serveur." },
|
|
1740
|
+
{ name: "task_transfer_multi", desc: "Transferts SFTP multiples avec patterns glob." },
|
|
1741
|
+
{ name: "task_queue", desc: "Voir toutes les tâches en cours/en attente." },
|
|
1742
|
+
{ name: "task_status", desc: "Détail d'une tâche par son ID." },
|
|
1743
|
+
{ name: "task_history", desc: "Historique des tâches, filtrable par alias." },
|
|
1744
|
+
{ name: "task_retry", desc: "Relancer une tâche échouée ou crashée." },
|
|
1745
|
+
{ name: "task_wait", desc: "Attendre la fin d'une tâche en arrière-plan (jusqu'à 600s)." },
|
|
1746
|
+
{ name: "task_logs", desc: "Logs internes du système MCP." },
|
|
1747
|
+
{ name: "queue_stats", desc: "Statistiques de la file d'attente." },
|
|
1748
|
+
{ name: "pool_stats", desc: "Statistiques du pool de connexions SSH." },
|
|
1749
|
+
{ name: "get_pm2_logs", desc: "Récupérer les logs PM2 d'une app." },
|
|
1750
|
+
{ name: "get_docker_logs", desc: "Récupérer les logs d'un container Docker." },
|
|
1751
|
+
{ name: "tail_file", desc: "Afficher les dernières lignes d'un fichier distant." },
|
|
1752
|
+
{ name: "file_read", desc: "Lire un fichier (local ou remote) + hash SHA-256. source:{type,path,alias?}." },
|
|
1753
|
+
{ name: "file_write", desc: "Créer/écraser un fichier (local ou remote). dryRun (preview) + backup (snapshot avant écrasement)." },
|
|
1754
|
+
{ name: "file_edit", desc: "Éditer chirurgical (oldString/newString) ou complet (newContent). dryRun + backup + protection hash." },
|
|
1755
|
+
{ name: "diff_files", desc: "Comparer 2 fichiers (local/remote, cross-server). Diff unifié + origine des sources." },
|
|
1756
|
+
{ name: "diff_folders", desc: "Comparer 2 dossiers : fichiers ajoutés/supprimés/modifiés. ignorePatterns supporté." },
|
|
1757
|
+
{ name: "compare_all_sources", desc: "Comparer un fichier sur N sources (parc). Regroupe par hash, détecte les drifts." },
|
|
1758
|
+
{ name: "shell_create", desc: "Ouvrir une session shell persistante (cd/export persistent) sur un serveur distant." },
|
|
1759
|
+
{ name: "shell_exec", desc: "Exécuter une commande dans une session shell persistante (état conservé)." },
|
|
1760
|
+
{ name: "shell_list", desc: "Lister les sessions shell actives (alias, âge, commandes, état)." },
|
|
1761
|
+
{ name: "shell_close", desc: "Fermer proprement une session shell persistante." },
|
|
1762
|
+
{ name: "snapshot_create", desc: "Capturer l'état de fichiers/dossiers (local/remote) avec déduplication." },
|
|
1763
|
+
{ name: "snapshot_list", desc: "Lister les snapshots d'infrastructure (filtrable par source/tag)." },
|
|
1764
|
+
{ name: "snapshot_diff", desc: "Comparer 2 snapshots (même cross local↔remote). Détection de drift." },
|
|
1765
|
+
{ name: "snapshot_restore", desc: "Restaurer un snapshot (dryRun par défaut, force requis pour écraser)." },
|
|
1766
|
+
{ name: "snapshot_delete", desc: "Supprimer un snapshot + nettoyer les blobs orphelins." },
|
|
1767
|
+
{ name: "guide", desc: "Manuel IA : workflows, cheatsheet, pièges (section:index/workflows/...)." },
|
|
1768
|
+
{ name: "server_note_set", desc: "Définir le contexte d'un serveur (description, services, warnings, conventions)." },
|
|
1769
|
+
{ name: "server_note_get", desc: "Lire le contexte/protocole d'un serveur (à consulter avant d'intervenir)." },
|
|
1770
|
+
{ name: "server_note_list", desc: "Vue d'ensemble des notes de tout le parc." },
|
|
1771
|
+
{ name: "server_note_remove", desc: "Supprimer la note d'un serveur." },
|
|
1772
|
+
{ name: "infra_overview", desc: "Vue synthétique du parc : serveurs + notes (rôle, services, warnings)." },
|
|
1773
|
+
{ name: "policy_blocklist_list", desc: "Lister les commandes bloquées par la politique de sécurité." },
|
|
1774
|
+
{ name: "policy_blocklist_add", desc: "Ajouter un pattern à la blocklist." },
|
|
1775
|
+
{ name: "policy_blocklist_remove", desc: "Retirer un pattern de la blocklist." },
|
|
1776
|
+
{ name: "tmux_create", desc: "Créer une session tmux persistante sur un serveur." },
|
|
1777
|
+
{ name: "tmux_exec", desc: "Envoyer une commande dans une session tmux." },
|
|
1778
|
+
{ name: "tmux_read", desc: "Lire le buffer d'une session tmux." },
|
|
1779
|
+
{ name: "tmux_list", desc: "Lister les sessions tmux actives." },
|
|
1780
|
+
{ name: "tmux_kill", desc: "Tuer une session tmux." },
|
|
1781
|
+
{ name: "tunnel_create", desc: "Créer un tunnel SSH (local/remote/socks)." },
|
|
1782
|
+
{ name: "tunnel_list", desc: "Lister les tunnels actifs." },
|
|
1783
|
+
{ name: "tunnel_close", desc: "Fermer un tunnel." },
|
|
1784
|
+
{ name: "tunnel_allowlist_add", desc: "Ajouter un port à l'allowlist." },
|
|
1785
|
+
{ name: "tunnel_allowlist_remove", desc: "Retirer un port de l'allowlist." }
|
|
1786
|
+
];
|
|
1787
|
+
|
|
1788
|
+
const envVars = [
|
|
1789
|
+
{ var: "MCP_DATA_DIR", defaut: "~/.config/mcp-orchestrator", desc: "Dossier des fichiers de données (JSON)." },
|
|
1790
|
+
{ var: "MCP_SYNC_TIMEOUT_S", defaut: "120", desc: "Délai en secondes avant passage en arrière-plan." },
|
|
1791
|
+
{ var: "MCP_DEFAULT_CMD_TIMEOUT_S", defaut: "600", desc: "Timeout SSH par défaut (0=infini)." },
|
|
1792
|
+
{ var: "MCP_INTERACTIVE_CMD_TIMEOUT_S", defaut: "300", desc: "Timeout mode interactif (0=infini)." },
|
|
1793
|
+
{ var: "MCP_MAX_WAIT_TIMEOUT_S", defaut: "600", desc: "Timeout maximum pour task_wait." },
|
|
1794
|
+
{ var: "MAX_CONNECTIONS_PER_SERVER", defaut: "5", desc: "Connexions SSH max par serveur." },
|
|
1795
|
+
{ var: "MIN_CONNECTIONS_PER_SERVER", defaut: "1", desc: "Connexions SSH min par serveur." },
|
|
1796
|
+
{ var: "IDLE_TIMEOUT", defaut: "300000", desc: "Délai avant fermeture connexion inactive (ms)." },
|
|
1797
|
+
{ var: "KEEP_ALIVE_INTERVAL", defaut: "30000", desc: "Intervalle keepalive SSH (ms)." },
|
|
1798
|
+
{ var: "MAX_QUEUE_SIZE", defaut: "1000", desc: "Nombre max de jobs dans la queue." },
|
|
1799
|
+
{ var: "SAVE_INTERVAL", defaut: "5000", desc: "Intervalle de sauvegarde de la queue (ms)." },
|
|
1800
|
+
{ var: "MCP_DEBUG", defaut: "false", desc: "Activer les logs de debug détaillés." },
|
|
1801
|
+
{ var: "MCP_TRANSPORT", defaut: "stdio", desc: "Transport: stdio, http, ou both (v9+)." },
|
|
1802
|
+
{ var: "MCP_HTTP_PORT", defaut: "3457", desc: "Port du serveur HTTP (si transport=http ou both)." }
|
|
1803
|
+
];
|
|
1804
|
+
|
|
1805
|
+
const usageTips = [
|
|
1806
|
+
"Pour les longues opérations: utilisez timeout:0 sur task_exec, puis task_wait pour récupérer le résultat.",
|
|
1807
|
+
"Transferts SFTP: si un fichier existe déjà, utilisez force:true pour l'écraser (sinon refusé).",
|
|
1808
|
+
"Mode interactif: passez un objet responses avec des patterns→réponses pour les prompts attendus.",
|
|
1809
|
+
"Les patterns dans responses supportent les regex. Ex: '[YyNn]\\\\?' → 'y'.",
|
|
1810
|
+
"Utilisez task_queue pour voir les jobs en cours, task_status <id> pour un job précis.",
|
|
1811
|
+
"Les jobs 'crashed' (rouges) sont réessayables avec task_retry."
|
|
1812
|
+
];
|
|
1813
|
+
|
|
1814
|
+
if (params.tool) {
|
|
1815
|
+
const tool = allTools.find(t => t.name === params.tool);
|
|
1816
|
+
if (!tool) {
|
|
1817
|
+
const errorPayload = { toolName: "help", errorCode: "UNKNOWN_TOOL", errorMessage: `Outil '${params.tool}' inconnu. Utilisez help sans paramètre pour la liste.` };
|
|
1818
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
1819
|
+
}
|
|
1820
|
+
const schema = TOOL_SCHEMAS[params.tool];
|
|
1821
|
+
let detail = `Outil: ${tool.name}\nDescription: ${tool.desc}`;
|
|
1822
|
+
if (schema) detail += `\n\n${schema}`;
|
|
1823
|
+
detail += `\n\nAstuce: guide section:audit pour l'audit complet du parc.`;
|
|
1824
|
+
return { content: [{ type: "text", text: detail }] };
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
const guide = `=== GUIDE MCP ORCHESTRATOR v${ORCH_VERSION} ===
|
|
1828
|
+
|
|
1829
|
+
OUTILS DISPONIBLES (${allTools.length}):
|
|
1830
|
+
${allTools.map(t => ` ${t.name}: ${t.desc}`).join('\n')}
|
|
1831
|
+
|
|
1832
|
+
VARIABLES D'ENVIRONNEMENT (.env):
|
|
1833
|
+
${envVars.map(v => ` ${v.var} (défaut: ${v.defaut}) — ${v.desc}`).join('\n')}
|
|
1834
|
+
|
|
1835
|
+
ASTUCES D'UTILISATION:
|
|
1836
|
+
${usageTips.map(t => ` • ${t}`).join('\n')}
|
|
1837
|
+
|
|
1838
|
+
Pour la doc complète: help tool:<nom> ou consultez le README.`;
|
|
1839
|
+
|
|
1840
|
+
return { content: [{ type: "text", text: guide }] };
|
|
1841
|
+
}
|
|
1842
|
+
);
|
|
1843
|
+
|
|
1844
|
+
// --- ATTENTE EXPLICITE ---
|
|
1845
|
+
|
|
1846
|
+
server.registerTool(
|
|
1847
|
+
"task_wait",
|
|
1848
|
+
{
|
|
1849
|
+
title: "Attendre la fin d'une tâche en arrière-plan",
|
|
1850
|
+
description: `Prend un ID de job et attend jusqu'à ${config.maxWaitTimeout / 1000}s que la tâche se termine, puis retourne le résultat.`,
|
|
1851
|
+
inputSchema: z.object({
|
|
1852
|
+
id: z.string().describe("L'ID de la tâche à attendre.")
|
|
1853
|
+
})
|
|
1854
|
+
},
|
|
1855
|
+
async (params) => {
|
|
1856
|
+
const existingJob = queue.getJob(params.id);
|
|
1857
|
+
if (!existingJob) {
|
|
1858
|
+
const errorPayload = {
|
|
1859
|
+
toolName: "task_wait",
|
|
1860
|
+
errorCode: "JOB_NOT_FOUND",
|
|
1861
|
+
errorMessage: `Tâche '${params.id}' introuvable.`
|
|
1862
|
+
};
|
|
1863
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
if (existingJob.status === 'completed' || existingJob.status === 'failed') {
|
|
1867
|
+
return { content: [{ type: "text", text: `Tâche ${params.id} déjà terminée:\n${JSON.stringify(existingJob, null, 2)}` }] };
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
const finalJob = await waitForJobCompletion(params.id, config.maxWaitTimeout);
|
|
1871
|
+
if (finalJob) {
|
|
1872
|
+
return { content: [{ type: "text", text: `Résultat tâche ${finalJob.id}:\n${JSON.stringify(finalJob, null, 2)}` }] };
|
|
1873
|
+
} else {
|
|
1874
|
+
return { content: [{ type: "text", text: `Tâche ${params.id} toujours en cours (peut-être terminée entre-temps). Réessayez task_wait.` }] };
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
);
|
|
1878
|
+
|
|
1879
|
+
// --- OPÉRATIONS FICHIERS (LOCAL + REMOTE) ---
|
|
1880
|
+
|
|
1881
|
+
// Résout la config serveur si remote, et valide la cohérence type/alias
|
|
1882
|
+
function resolveSource(source) {
|
|
1883
|
+
if (source.type === 'remote' && !source.alias) {
|
|
1884
|
+
throw new Error("Source remote invalide : 'alias' est requis quand type='remote'.");
|
|
1885
|
+
}
|
|
1886
|
+
return source; // sourceAdapter récupère la config via serverManager.getServer(alias)
|
|
365
1887
|
}
|
|
366
1888
|
|
|
367
|
-
// --- EXÉCUTION DE TÂCHES ---
|
|
368
1889
|
server.registerTool(
|
|
369
|
-
"
|
|
1890
|
+
"file_read",
|
|
370
1891
|
{
|
|
371
|
-
title: "
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
1892
|
+
title: "Lire un fichier (local ou distant)",
|
|
1893
|
+
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).",
|
|
1894
|
+
inputSchema: z.object({
|
|
1895
|
+
source: sourceSchema,
|
|
1896
|
+
encoding: z.enum(['utf8', 'base64']).optional().default('utf8'),
|
|
1897
|
+
offset: z.number().int().positive().optional().describe("Ligne de départ (1-indexée) pour lecture partielle"),
|
|
1898
|
+
limit: z.number().int().positive().optional().describe("Nombre de lignes à retourner (lecture partielle)")
|
|
1899
|
+
})
|
|
1900
|
+
},
|
|
1901
|
+
async (params) => {
|
|
1902
|
+
try {
|
|
1903
|
+
const source = resolveSource(params.source);
|
|
1904
|
+
const result = await fileOps.readFile(source, params.encoding, {
|
|
1905
|
+
offset: params.offset,
|
|
1906
|
+
limit: params.limit
|
|
1907
|
+
});
|
|
1908
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1909
|
+
} catch (e) {
|
|
1910
|
+
const errorPayload = { toolName: "file_read", errorCode: "READ_ERROR", errorMessage: e.message };
|
|
1911
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
);
|
|
1915
|
+
|
|
1916
|
+
server.registerTool(
|
|
1917
|
+
"file_write",
|
|
1918
|
+
{
|
|
1919
|
+
title: "Écrire un fichier (local ou distant)",
|
|
1920
|
+
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).",
|
|
1921
|
+
inputSchema: z.object({
|
|
1922
|
+
source: sourceSchema,
|
|
1923
|
+
content: z.string().describe("Contenu à écrire"),
|
|
1924
|
+
encoding: z.enum(['utf8', 'base64']).optional().default('utf8'),
|
|
1925
|
+
createDirs: z.boolean().optional().default(true),
|
|
1926
|
+
dryRun: z.boolean().optional().default(false).describe("Prévisualiser le diff sans écrire"),
|
|
1927
|
+
backup: z.boolean().optional().default(false).describe("Snapshot du fichier existant avant écrasement (filet de sécurité)")
|
|
1928
|
+
})
|
|
1929
|
+
},
|
|
1930
|
+
async (params) => {
|
|
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
|
+
}
|
|
1938
|
+
const source = resolveSource(params.source);
|
|
1939
|
+
const result = await fileOps.writeFile(source, params.content, params.encoding, {
|
|
1940
|
+
createDirs: params.createDirs,
|
|
1941
|
+
dryRun: params.dryRun,
|
|
1942
|
+
backup: params.backup
|
|
1943
|
+
});
|
|
1944
|
+
// Affichage client : si un diff est présent (dryRun), le rendre en markdown lisible
|
|
1945
|
+
if (result.diff && result.diff !== '(binaire — diff non affiché)') {
|
|
1946
|
+
const title = result.dryRun ? `Prévisualisation écriture — ${result.origin.label}` : result.origin.label;
|
|
1947
|
+
const fmt = diffFormatter.format(result.diff, { title });
|
|
1948
|
+
return diffFormatter.response(fmt.markdown, result);
|
|
1949
|
+
}
|
|
1950
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1951
|
+
} catch (e) {
|
|
1952
|
+
const errorPayload = { toolName: "file_write", errorCode: "WRITE_ERROR", errorMessage: e.message };
|
|
1953
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
);
|
|
1957
|
+
|
|
1958
|
+
server.registerTool(
|
|
1959
|
+
"file_edit",
|
|
1960
|
+
{
|
|
1961
|
+
title: "Éditer un fichier (local ou distant) — chirurgical ou complet",
|
|
1962
|
+
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.",
|
|
1963
|
+
inputSchema: z.object({
|
|
1964
|
+
source: sourceSchema,
|
|
1965
|
+
oldString: z.string().optional().describe("Mode chirurgical : texte exact à remplacer (avec indentation/espaces)"),
|
|
1966
|
+
newString: z.string().optional().describe("Mode chirurgical : texte de remplacement"),
|
|
1967
|
+
replaceAll: z.boolean().optional().default(false).describe("Mode chirurgical : remplacer toutes les occurrences (sinon erreur si multiples)"),
|
|
1968
|
+
newContent: z.string().optional().describe("Mode complet : nouveau contenu intégral du fichier"),
|
|
1969
|
+
expectedHash: z.string().optional().describe("Hash SHA-256 obtenu via file_read (protection anti-écrasement, recommandé)"),
|
|
1970
|
+
encoding: z.enum(['utf8', 'base64']).optional().default('utf8'),
|
|
1971
|
+
dryRun: z.boolean().optional().default(false).describe("Prévisualiser le diff sans écrire"),
|
|
1972
|
+
backup: z.boolean().optional().default(false).describe("Snapshot du fichier avant modification (filet de sécurité)")
|
|
1973
|
+
})
|
|
1974
|
+
},
|
|
1975
|
+
async (params) => {
|
|
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
|
+
}
|
|
1983
|
+
const source = resolveSource(params.source);
|
|
1984
|
+
const result = await fileOps.editFile(source, {
|
|
1985
|
+
oldString: params.oldString,
|
|
1986
|
+
newString: params.newString,
|
|
1987
|
+
replaceAll: params.replaceAll,
|
|
1988
|
+
newContent: params.newContent,
|
|
1989
|
+
expectedHash: params.expectedHash,
|
|
1990
|
+
encoding: params.encoding,
|
|
1991
|
+
dryRun: params.dryRun,
|
|
1992
|
+
backup: params.backup
|
|
1993
|
+
});
|
|
1994
|
+
// Affichage client : diff rendu en markdown lisible (```diff coloré)
|
|
1995
|
+
if (result.diff) {
|
|
1996
|
+
const title = result.dryRun
|
|
1997
|
+
? `Prévisualisation édition — ${result.origin.label}`
|
|
1998
|
+
: `Édition appliquée — ${result.origin.label}`;
|
|
1999
|
+
const fmt = diffFormatter.format(result.diff, { title });
|
|
2000
|
+
return diffFormatter.response(fmt.markdown, result);
|
|
2001
|
+
}
|
|
2002
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2003
|
+
} catch (e) {
|
|
2004
|
+
if (e.code === 'HASH_MISMATCH') {
|
|
2005
|
+
const errorPayload = {
|
|
2006
|
+
toolName: "file_edit",
|
|
2007
|
+
errorCode: "HASH_MISMATCH",
|
|
2008
|
+
errorMessage: e.message,
|
|
2009
|
+
currentHash: e.current?.hash,
|
|
2010
|
+
currentContent: e.current?.content
|
|
2011
|
+
};
|
|
2012
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2013
|
+
}
|
|
2014
|
+
if (e.code === 'OLDSTRING_NOT_FOUND' || e.code === 'MULTIPLE_MATCHES') {
|
|
2015
|
+
const errorPayload = {
|
|
2016
|
+
toolName: "file_edit",
|
|
2017
|
+
errorCode: e.code,
|
|
2018
|
+
errorMessage: e.message,
|
|
2019
|
+
...(e.occurrences ? { occurrences: e.occurrences } : {})
|
|
2020
|
+
};
|
|
2021
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2022
|
+
}
|
|
2023
|
+
const errorPayload = { toolName: "file_edit", errorCode: "EDIT_ERROR", errorMessage: e.message };
|
|
2024
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
);
|
|
2028
|
+
|
|
2029
|
+
// --- COMPARAISON / DIFF (LOCAL + REMOTE, CROSS-SERVER) ---
|
|
2030
|
+
|
|
2031
|
+
server.registerTool(
|
|
2032
|
+
"diff_files",
|
|
2033
|
+
{
|
|
2034
|
+
title: "Comparer deux fichiers (local/remote, cross-server)",
|
|
2035
|
+
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.",
|
|
2036
|
+
inputSchema: z.object({
|
|
2037
|
+
source1: sourceSchema,
|
|
2038
|
+
source2: sourceSchema
|
|
2039
|
+
})
|
|
2040
|
+
},
|
|
2041
|
+
async (params) => {
|
|
2042
|
+
try {
|
|
2043
|
+
const s1 = resolveSource(params.source1);
|
|
2044
|
+
const s2 = resolveSource(params.source2);
|
|
2045
|
+
const result = await diffEngine.diffFiles(s1, s2);
|
|
2046
|
+
// Affichage client : diff en markdown si les fichiers diffèrent
|
|
2047
|
+
if (!result.identical && result.diff) {
|
|
2048
|
+
const title = `${result.source1.label} → ${result.source2.label}`;
|
|
2049
|
+
const fmt = diffFormatter.format(result.diff, { title });
|
|
2050
|
+
return diffFormatter.response(fmt.markdown, result);
|
|
2051
|
+
}
|
|
2052
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2053
|
+
} catch (e) {
|
|
2054
|
+
const errorPayload = { toolName: "diff_files", errorCode: "DIFF_ERROR", errorMessage: e.message };
|
|
2055
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
);
|
|
2059
|
+
|
|
2060
|
+
server.registerTool(
|
|
2061
|
+
"diff_folders",
|
|
2062
|
+
{
|
|
2063
|
+
title: "Comparer deux dossiers (local/remote, cross-server)",
|
|
2064
|
+
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']).",
|
|
2065
|
+
inputSchema: z.object({
|
|
2066
|
+
source1: sourceSchema,
|
|
2067
|
+
source2: sourceSchema,
|
|
2068
|
+
recursive: z.boolean().optional().default(true),
|
|
2069
|
+
compareContent: z.boolean().optional().default(true),
|
|
2070
|
+
includeDiff: z.boolean().optional().default(false).describe("Inclure le diff unifié complet pour chaque fichier modifié"),
|
|
2071
|
+
ignorePatterns: z.array(z.string()).optional().describe("Motifs glob à ignorer (ex: ['node_modules', '*.log', '.git'])")
|
|
2072
|
+
})
|
|
2073
|
+
},
|
|
2074
|
+
async (params) => {
|
|
2075
|
+
try {
|
|
2076
|
+
const s1 = resolveSource(params.source1);
|
|
2077
|
+
const s2 = resolveSource(params.source2);
|
|
2078
|
+
const result = await diffEngine.diffFolders(s1, s2, {
|
|
2079
|
+
recursive: params.recursive,
|
|
2080
|
+
compareContent: params.compareContent,
|
|
2081
|
+
includeDiff: params.includeDiff,
|
|
2082
|
+
ignorePatterns: params.ignorePatterns
|
|
2083
|
+
});
|
|
2084
|
+
// Affichage client : résumé lisible des fichiers (modifiés/ajoutés/supprimés)
|
|
2085
|
+
const hasChanges = result.modified.length || result.only_in_source1.length || result.only_in_source2.length;
|
|
2086
|
+
if (hasChanges) {
|
|
2087
|
+
const md = diffFormatter.formatFileList({
|
|
2088
|
+
modified: result.modified,
|
|
2089
|
+
added: result.only_in_source2, // présents côté source2 uniquement
|
|
2090
|
+
removed: result.only_in_source1 // présents côté source1 uniquement
|
|
2091
|
+
}, { title: `${result.source1.label} → ${result.source2.label}` });
|
|
2092
|
+
return { content: [{ type: "text", text: md }, { type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2093
|
+
}
|
|
2094
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2095
|
+
} catch (e) {
|
|
2096
|
+
const errorPayload = { toolName: "diff_folders", errorCode: "DIFF_ERROR", errorMessage: e.message };
|
|
2097
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
);
|
|
2101
|
+
|
|
2102
|
+
server.registerTool(
|
|
2103
|
+
"compare_all_sources",
|
|
2104
|
+
{
|
|
2105
|
+
title: "Comparer un fichier sur plusieurs sources (détection drift)",
|
|
2106
|
+
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.",
|
|
2107
|
+
inputSchema: z.object({
|
|
2108
|
+
sources: z.array(sourceSchema).min(2).describe("Liste de sources à comparer (2+, local et/ou remote). Chaque source peut avoir path différent + label."),
|
|
2109
|
+
includeContent: z.boolean().optional().default(false).describe("Inclure le contenu complet de chaque version distincte"),
|
|
2110
|
+
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")
|
|
2111
|
+
})
|
|
2112
|
+
},
|
|
2113
|
+
async (params) => {
|
|
2114
|
+
try {
|
|
2115
|
+
const sources = params.sources.map(resolveSource);
|
|
2116
|
+
const result = await compareEngine.compareSources(sources, {
|
|
2117
|
+
includeContent: params.includeContent,
|
|
2118
|
+
includeDiff: params.includeDiff
|
|
2119
|
+
});
|
|
2120
|
+
// Affichage client : si drift, rendre le(s) diff(s) référence→divergence en markdown
|
|
2121
|
+
if (result.drift && result.drifts && result.drifts.length > 0) {
|
|
2122
|
+
const parts = [`### ⚠️ Drift détecté — ${result.stats.uniqueVersions} versions sur ${result.stats.totalSources} sources`];
|
|
2123
|
+
parts.push(`Référence : \`${result.reference.label}\` (${result.reference.sources.length} source(s))\n`);
|
|
2124
|
+
for (const d of result.drifts) {
|
|
2125
|
+
if (d.diff) {
|
|
2126
|
+
const fmt = diffFormatter.format(d.diff, { title: `${d.from} → ${d.to}` });
|
|
2127
|
+
parts.push(fmt.markdown);
|
|
379
2128
|
}
|
|
2129
|
+
}
|
|
2130
|
+
return { content: [{ type: "text", text: parts.join('\n\n') }, { type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2131
|
+
}
|
|
2132
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2133
|
+
} catch (e) {
|
|
2134
|
+
const errorPayload = { toolName: "compare_all_sources", errorCode: "COMPARE_ERROR", errorMessage: e.message };
|
|
2135
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
);
|
|
2139
|
+
|
|
2140
|
+
// --- SESSIONS SHELL PERSISTANTES (remote) ---
|
|
2141
|
+
|
|
2142
|
+
server.registerTool(
|
|
2143
|
+
"shell_create",
|
|
2144
|
+
{
|
|
2145
|
+
title: "Créer une session shell persistante",
|
|
2146
|
+
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é.",
|
|
2147
|
+
inputSchema: z.object({
|
|
2148
|
+
alias: z.string().describe("Alias du serveur cible"),
|
|
2149
|
+
workdir: z.string().optional().describe("Répertoire de démarrage (cd initial)"),
|
|
2150
|
+
env: z.record(z.string()).optional().describe("Variables d'environnement initiales {CLE: valeur}"),
|
|
2151
|
+
cmdTimeout: z.number().optional().describe("Timeout par défaut des commandes en secondes (défaut 300, 0=infini)")
|
|
2152
|
+
})
|
|
380
2153
|
},
|
|
381
2154
|
async (params) => {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
2155
|
+
try {
|
|
2156
|
+
const result = await shellSessions.createSession(params.alias, {
|
|
2157
|
+
workdir: params.workdir,
|
|
2158
|
+
env: params.env,
|
|
2159
|
+
cmdTimeout: params.cmdTimeout
|
|
2160
|
+
});
|
|
2161
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2162
|
+
} catch (e) {
|
|
2163
|
+
const errorPayload = { toolName: "shell_create", errorCode: "SHELL_CREATE_ERROR", errorMessage: e.message };
|
|
2164
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
);
|
|
385
2168
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
|
|
2169
|
+
server.registerTool(
|
|
2170
|
+
"shell_exec",
|
|
2171
|
+
{
|
|
2172
|
+
title: "Exécuter dans une session shell persistante",
|
|
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).",
|
|
2174
|
+
inputSchema: z.object({
|
|
2175
|
+
sessionId: z.string().describe("ID de session obtenu via shell_create"),
|
|
2176
|
+
command: z.string().describe("Commande à exécuter"),
|
|
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).")
|
|
2179
|
+
})
|
|
2180
|
+
},
|
|
2181
|
+
async (params) => {
|
|
2182
|
+
try {
|
|
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
|
+
});
|
|
2190
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2191
|
+
} catch (e) {
|
|
2192
|
+
const errorPayload = { toolName: "shell_exec", errorCode: "SHELL_EXEC_ERROR", errorMessage: e.message };
|
|
2193
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
392
2194
|
}
|
|
393
2195
|
}
|
|
394
2196
|
);
|
|
395
2197
|
|
|
396
2198
|
server.registerTool(
|
|
397
|
-
"
|
|
2199
|
+
"shell_list",
|
|
2200
|
+
{
|
|
2201
|
+
title: "Lister les sessions shell actives",
|
|
2202
|
+
description: "Affiche toutes les sessions shell persistantes en cours avec leur état (alias, âge, inactivité, nombre de commandes, occupée/fermée).",
|
|
2203
|
+
inputSchema: z.object({})
|
|
2204
|
+
},
|
|
2205
|
+
async () => {
|
|
2206
|
+
try {
|
|
2207
|
+
const sessions = shellSessions.listSessions();
|
|
2208
|
+
return { content: [{ type: "text", text: JSON.stringify({ sessions, count: sessions.length }, null, 2) }] };
|
|
2209
|
+
} catch (e) {
|
|
2210
|
+
const errorPayload = { toolName: "shell_list", errorCode: "SHELL_LIST_ERROR", errorMessage: e.message };
|
|
2211
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
);
|
|
2215
|
+
|
|
2216
|
+
server.registerTool(
|
|
2217
|
+
"shell_close",
|
|
2218
|
+
{
|
|
2219
|
+
title: "Fermer une session shell persistante",
|
|
2220
|
+
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.",
|
|
2221
|
+
inputSchema: z.object({
|
|
2222
|
+
sessionId: z.string().describe("ID de session à fermer")
|
|
2223
|
+
})
|
|
2224
|
+
},
|
|
2225
|
+
async (params) => {
|
|
2226
|
+
try {
|
|
2227
|
+
const result = shellSessions.closeSession(params.sessionId);
|
|
2228
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2229
|
+
} catch (e) {
|
|
2230
|
+
const errorPayload = { toolName: "shell_close", errorCode: "SHELL_CLOSE_ERROR", errorMessage: e.message };
|
|
2231
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
);
|
|
2235
|
+
|
|
2236
|
+
// --- SNAPSHOTS D'INFRASTRUCTURE (local + remote, versioning + dedup) ---
|
|
2237
|
+
|
|
2238
|
+
server.registerTool(
|
|
2239
|
+
"snapshot_create",
|
|
2240
|
+
{
|
|
2241
|
+
title: "Créer un snapshot d'infrastructure",
|
|
2242
|
+
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.",
|
|
2243
|
+
inputSchema: z.object({
|
|
2244
|
+
source: z.object({
|
|
2245
|
+
type: z.enum(['local', 'remote']),
|
|
2246
|
+
alias: z.string().optional().describe("Requis si type='remote'")
|
|
2247
|
+
}),
|
|
2248
|
+
paths: z.array(z.string()).min(1).describe("Chemins absolus à capturer (fichiers ou dossiers)"),
|
|
2249
|
+
tag: z.string().optional().describe("Étiquette (ex: 'before-nginx-fix')"),
|
|
2250
|
+
message: z.string().optional(),
|
|
2251
|
+
recursive: z.boolean().optional().default(true),
|
|
2252
|
+
ignorePatterns: z.array(z.string()).optional().describe("Motifs glob à exclure (ex: ['node_modules','*.log'])")
|
|
2253
|
+
})
|
|
2254
|
+
},
|
|
2255
|
+
async (params) => {
|
|
2256
|
+
try {
|
|
2257
|
+
if (params.source.type === 'remote' && !params.source.alias) {
|
|
2258
|
+
throw new Error("alias requis quand type='remote'.");
|
|
2259
|
+
}
|
|
2260
|
+
const result = await snapshotManager.createSnapshot(params.source, params.paths, {
|
|
2261
|
+
tag: params.tag,
|
|
2262
|
+
message: params.message,
|
|
2263
|
+
recursive: params.recursive,
|
|
2264
|
+
ignorePatterns: params.ignorePatterns
|
|
2265
|
+
});
|
|
2266
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2267
|
+
} catch (e) {
|
|
2268
|
+
const errorPayload = { toolName: "snapshot_create", errorCode: "SNAPSHOT_ERROR", errorMessage: e.message };
|
|
2269
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
);
|
|
2273
|
+
|
|
2274
|
+
server.registerTool(
|
|
2275
|
+
"snapshot_list",
|
|
2276
|
+
{
|
|
2277
|
+
title: "Lister les snapshots d'infrastructure",
|
|
2278
|
+
description: "Affiche les snapshots filtrés par source (localhost, serveur, tag). Résumé léger (id, serveur, tag, date, nb fichiers).",
|
|
2279
|
+
inputSchema: z.object({
|
|
2280
|
+
sourceType: z.enum(['local', 'remote']).optional(),
|
|
2281
|
+
sourceAlias: z.string().optional(),
|
|
2282
|
+
tag: z.string().optional(),
|
|
2283
|
+
limit: z.number().optional().default(20)
|
|
2284
|
+
})
|
|
2285
|
+
},
|
|
2286
|
+
async (params) => {
|
|
2287
|
+
try {
|
|
2288
|
+
const result = await snapshotManager.listSnapshots({
|
|
2289
|
+
sourceType: params.sourceType,
|
|
2290
|
+
sourceAlias: params.sourceAlias,
|
|
2291
|
+
tag: params.tag,
|
|
2292
|
+
limit: params.limit
|
|
2293
|
+
});
|
|
2294
|
+
return { content: [{ type: "text", text: JSON.stringify({ snapshots: result, count: result.length }, null, 2) }] };
|
|
2295
|
+
} catch (e) {
|
|
2296
|
+
const errorPayload = { toolName: "snapshot_list", errorCode: "LIST_ERROR", errorMessage: e.message };
|
|
2297
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
);
|
|
2301
|
+
|
|
2302
|
+
server.registerTool(
|
|
2303
|
+
"snapshot_diff",
|
|
398
2304
|
{
|
|
399
|
-
title: "
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
2305
|
+
title: "Comparer deux snapshots",
|
|
2306
|
+
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.",
|
|
2307
|
+
inputSchema: z.object({
|
|
2308
|
+
snapshot1: z.string().describe("ID ou tag du premier snapshot"),
|
|
2309
|
+
snapshot2: z.string().describe("ID ou tag du second snapshot"),
|
|
2310
|
+
includeDiff: z.boolean().optional().default(false)
|
|
2311
|
+
})
|
|
2312
|
+
},
|
|
2313
|
+
async (params) => {
|
|
2314
|
+
try {
|
|
2315
|
+
const result = await snapshotManager.diffSnapshots(params.snapshot1, params.snapshot2, {
|
|
2316
|
+
includeDiff: params.includeDiff
|
|
2317
|
+
});
|
|
2318
|
+
// Affichage client : résumé lisible des fichiers changés entre snapshots
|
|
2319
|
+
const hasChanges = result.modified.length || result.added.length || result.removed.length;
|
|
2320
|
+
if (hasChanges) {
|
|
2321
|
+
let md = diffFormatter.formatFileList({
|
|
2322
|
+
modified: result.modified,
|
|
2323
|
+
added: result.added,
|
|
2324
|
+
removed: result.removed
|
|
2325
|
+
}, { title: `${result.snapshot1.id} → ${result.snapshot2.id}` });
|
|
2326
|
+
// Si includeDiff, ajoute les diffs détaillés des fichiers modifiés
|
|
2327
|
+
if (params.includeDiff) {
|
|
2328
|
+
for (const m of result.modified) {
|
|
2329
|
+
if (m.diff) md += '\n\n' + diffFormatter.format(m.diff, { title: m.path }).markdown;
|
|
405
2330
|
}
|
|
2331
|
+
}
|
|
2332
|
+
return { content: [{ type: "text", text: md }, { type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2333
|
+
}
|
|
2334
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2335
|
+
} catch (e) {
|
|
2336
|
+
const errorPayload = { toolName: "snapshot_diff", errorCode: "DIFF_ERROR", errorMessage: e.message };
|
|
2337
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
);
|
|
2341
|
+
|
|
2342
|
+
server.registerTool(
|
|
2343
|
+
"snapshot_restore",
|
|
2344
|
+
{
|
|
2345
|
+
title: "Restaurer un snapshot",
|
|
2346
|
+
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).",
|
|
2347
|
+
inputSchema: z.object({
|
|
2348
|
+
snapshotId: z.string().describe("ID ou tag du snapshot"),
|
|
2349
|
+
target: z.object({
|
|
2350
|
+
type: z.enum(['local', 'remote']),
|
|
2351
|
+
alias: z.string().optional().describe("Requis si type='remote'")
|
|
2352
|
+
}),
|
|
2353
|
+
paths: z.array(z.string()).optional().describe("Restaurer uniquement ces chemins (préfixes)"),
|
|
2354
|
+
dryRun: z.boolean().optional().default(true),
|
|
2355
|
+
force: z.boolean().optional().default(false)
|
|
2356
|
+
})
|
|
406
2357
|
},
|
|
407
2358
|
async (params) => {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
2359
|
+
try {
|
|
2360
|
+
if (params.target.type === 'remote' && !params.target.alias) {
|
|
2361
|
+
throw new Error("alias requis quand type='remote'.");
|
|
2362
|
+
}
|
|
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
|
|
2369
|
+
if (!params.dryRun && !params.force) {
|
|
2370
|
+
const errorPayload = {
|
|
2371
|
+
toolName: "snapshot_restore",
|
|
2372
|
+
errorCode: "CONFIRMATION_REQUIRED",
|
|
2373
|
+
errorMessage: "Restauration réelle bloquée. Faites d'abord un dryRun:true pour prévisualiser, puis dryRun:false + force:true pour confirmer.",
|
|
2374
|
+
hint: "Toujours valider avec l'utilisateur avant force:true (écrasement de fichiers)."
|
|
2375
|
+
};
|
|
2376
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2377
|
+
}
|
|
2378
|
+
const result = await snapshotManager.restoreSnapshot(params.snapshotId, params.target, {
|
|
2379
|
+
paths: params.paths,
|
|
2380
|
+
dryRun: params.dryRun,
|
|
2381
|
+
force: params.force
|
|
2382
|
+
});
|
|
2383
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2384
|
+
} catch (e) {
|
|
2385
|
+
const errorPayload = { toolName: "snapshot_restore", errorCode: "RESTORE_ERROR", errorMessage: e.message };
|
|
2386
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
);
|
|
411
2390
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
2391
|
+
server.registerTool(
|
|
2392
|
+
"snapshot_delete",
|
|
2393
|
+
{
|
|
2394
|
+
title: "Supprimer un snapshot",
|
|
2395
|
+
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é.",
|
|
2396
|
+
inputSchema: z.object({
|
|
2397
|
+
snapshotId: z.string().describe("ID ou tag du snapshot à supprimer")
|
|
2398
|
+
})
|
|
2399
|
+
},
|
|
2400
|
+
async (params) => {
|
|
2401
|
+
try {
|
|
2402
|
+
guardWritable('snapshot_delete');
|
|
2403
|
+
const result = await snapshotManager.deleteSnapshot(params.snapshotId);
|
|
2404
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2405
|
+
} catch (e) {
|
|
2406
|
+
const errorPayload = { toolName: "snapshot_delete", errorCode: "DELETE_ERROR", errorMessage: e.message };
|
|
2407
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
);
|
|
2411
|
+
|
|
2412
|
+
// --- NOTES / PROTOCOLE PAR SERVEUR (mémoire contextuelle du parc) ---
|
|
2413
|
+
|
|
2414
|
+
server.registerTool(
|
|
2415
|
+
"server_note_set",
|
|
2416
|
+
{
|
|
2417
|
+
title: "Définir la note/protocole d'un serveur",
|
|
2418
|
+
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.",
|
|
2419
|
+
inputSchema: z.object({
|
|
2420
|
+
alias: z.string().describe("Alias du serveur"),
|
|
2421
|
+
description: z.string().optional().describe("Rôle du serveur"),
|
|
2422
|
+
services: z.array(z.string()).optional().describe("Services critiques (ex: ['nginx','pm2:proxy-gw'])"),
|
|
2423
|
+
warnings: z.array(z.string()).optional().describe("Avertissements (ex: ['RAM 1Go','ne pas rebuild en prod'])"),
|
|
2424
|
+
conventions: z.array(z.string()).optional().describe("Conventions/chemins (ex: ['configs dans /etc/nginx'])"),
|
|
2425
|
+
note: z.string().optional().describe("Note libre"),
|
|
2426
|
+
intervention: z.string().optional().describe("Résumé de la dernière intervention (horodatée automatiquement)")
|
|
2427
|
+
})
|
|
2428
|
+
},
|
|
2429
|
+
async (params) => {
|
|
2430
|
+
try {
|
|
2431
|
+
guardWritable('server_note_set');
|
|
2432
|
+
const { alias, ...fields } = params;
|
|
2433
|
+
const result = await notes.set(alias, fields);
|
|
2434
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2435
|
+
} catch (e) {
|
|
2436
|
+
const errorPayload = { toolName: "server_note_set", errorCode: "NOTE_ERROR", errorMessage: e.message };
|
|
2437
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
);
|
|
2441
|
+
|
|
2442
|
+
server.registerTool(
|
|
2443
|
+
"server_note_get",
|
|
2444
|
+
{
|
|
2445
|
+
title: "Lire la note/protocole d'un serveur",
|
|
2446
|
+
description: "Récupère le contexte enregistré d'un serveur (description, services, warnings, conventions, dernière intervention). À consulter avant d'intervenir sur un serveur.",
|
|
2447
|
+
inputSchema: z.object({
|
|
2448
|
+
alias: z.string().describe("Alias du serveur")
|
|
2449
|
+
})
|
|
2450
|
+
},
|
|
2451
|
+
async (params) => {
|
|
2452
|
+
try {
|
|
2453
|
+
const note = await notes.get(params.alias);
|
|
2454
|
+
if (!note) {
|
|
2455
|
+
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) }] };
|
|
2456
|
+
}
|
|
2457
|
+
return { content: [{ type: "text", text: JSON.stringify({ alias: params.alias, ...note }, null, 2) }] };
|
|
2458
|
+
} catch (e) {
|
|
2459
|
+
const errorPayload = { toolName: "server_note_get", errorCode: "NOTE_ERROR", errorMessage: e.message };
|
|
2460
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
418
2461
|
}
|
|
419
2462
|
}
|
|
420
2463
|
);
|
|
421
2464
|
|
|
422
|
-
// --- OUTILS DE SUIVI ET HISTORIQUE ---
|
|
423
|
-
function formatJobForDisplay(job) {
|
|
424
|
-
const displayJob = { ...job };
|
|
425
|
-
if (job.status === 'running' && job.reminderAt && new Date() > new Date(job.reminderAt)) {
|
|
426
|
-
displayJob.reminder = "ATTENTION: Le temps de rappel est écoulé. La tâche est peut-être terminée ou bloquée.";
|
|
427
|
-
}
|
|
428
|
-
return displayJob;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
|
|
432
2465
|
server.registerTool(
|
|
433
|
-
"
|
|
2466
|
+
"server_note_list",
|
|
434
2467
|
{
|
|
435
|
-
title: "
|
|
436
|
-
description: "
|
|
437
|
-
inputSchema: {}
|
|
2468
|
+
title: "Lister toutes les notes de serveurs",
|
|
2469
|
+
description: "Vue d'ensemble des notes/protocoles de tout le parc.",
|
|
2470
|
+
inputSchema: z.object({})
|
|
438
2471
|
},
|
|
439
2472
|
async () => {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
2473
|
+
try {
|
|
2474
|
+
const all = await notes.list();
|
|
2475
|
+
return { content: [{ type: "text", text: JSON.stringify({ notes: all, count: Object.keys(all).length }, null, 2) }] };
|
|
2476
|
+
} catch (e) {
|
|
2477
|
+
const errorPayload = { toolName: "server_note_list", errorCode: "NOTE_ERROR", errorMessage: e.message };
|
|
2478
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2479
|
+
}
|
|
443
2480
|
}
|
|
444
2481
|
);
|
|
445
2482
|
|
|
446
2483
|
server.registerTool(
|
|
447
|
-
"
|
|
2484
|
+
"server_note_remove",
|
|
448
2485
|
{
|
|
449
|
-
title: "
|
|
450
|
-
description: "
|
|
451
|
-
inputSchema: {
|
|
452
|
-
|
|
453
|
-
}
|
|
2486
|
+
title: "Supprimer la note d'un serveur",
|
|
2487
|
+
description: "Supprime le contexte enregistré d'un serveur.",
|
|
2488
|
+
inputSchema: z.object({
|
|
2489
|
+
alias: z.string().describe("Alias du serveur")
|
|
2490
|
+
})
|
|
454
2491
|
},
|
|
455
2492
|
async (params) => {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
2493
|
+
try {
|
|
2494
|
+
guardWritable('server_note_remove');
|
|
2495
|
+
const result = await notes.remove(params.alias);
|
|
2496
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2497
|
+
} catch (e) {
|
|
2498
|
+
const errorPayload = { toolName: "server_note_remove", errorCode: "NOTE_ERROR", errorMessage: e.message };
|
|
2499
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2500
|
+
}
|
|
461
2501
|
}
|
|
462
2502
|
);
|
|
463
2503
|
|
|
2504
|
+
// --- VUE SYNTHÉTIQUE / TOPOLOGIE DU PARC ---
|
|
2505
|
+
|
|
464
2506
|
server.registerTool(
|
|
465
|
-
"
|
|
2507
|
+
"infra_overview",
|
|
466
2508
|
{
|
|
467
|
-
title: "
|
|
468
|
-
description: "
|
|
469
|
-
inputSchema: {
|
|
470
|
-
alias: z.string().optional().describe("
|
|
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())
|
|
471
2523
|
}
|
|
472
2524
|
},
|
|
473
2525
|
async (params) => {
|
|
474
|
-
|
|
475
|
-
|
|
2526
|
+
try {
|
|
2527
|
+
const serverList = await servers.listServers();
|
|
2528
|
+
const allNotes = await notes.list();
|
|
2529
|
+
const registry = Object.entries(serverList).map(([alias, cfg]) => {
|
|
2530
|
+
const n = allNotes[alias] || {};
|
|
2531
|
+
return {
|
|
2532
|
+
alias,
|
|
2533
|
+
host: cfg.host,
|
|
2534
|
+
user: cfg.user,
|
|
2535
|
+
port: utils.resolveSshPort(cfg),
|
|
2536
|
+
readonly: cfg.readonly === true || cfg.readOnly === true,
|
|
2537
|
+
description: n.description || null,
|
|
2538
|
+
services: n.services || [],
|
|
2539
|
+
warnings: n.warnings || [],
|
|
2540
|
+
conventions: n.conventions || [],
|
|
2541
|
+
lastIntervention: n.last_intervention || null
|
|
2542
|
+
};
|
|
2543
|
+
});
|
|
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
|
+
};
|
|
2577
|
+
} catch (e) {
|
|
2578
|
+
const errorPayload = { toolName: "infra_overview", errorCode: "OVERVIEW_ERROR", errorMessage: e.message };
|
|
2579
|
+
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2580
|
+
}
|
|
476
2581
|
}
|
|
477
2582
|
);
|
|
478
2583
|
|
|
479
|
-
// ---
|
|
2584
|
+
// --- OUTILS TMUX (sessions terminal persistantes) ---
|
|
2585
|
+
|
|
2586
|
+
// Helper: vérifie si tmux est installé sur le serveur, retourne un message si absent
|
|
2587
|
+
async function checkTmux(alias) {
|
|
2588
|
+
const job = queue.addJob({ type: 'ssh', alias, cmd: "command -v tmux 2>/dev/null", streaming: false, status: 'pending' });
|
|
2589
|
+
ssh.executeCommand(job.id);
|
|
2590
|
+
const result = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
2591
|
+
if (!result || result.status === 'failed' || !result.output || result.output.trim() === '') {
|
|
2592
|
+
return { installed: false, message: `❌ tmux n'est pas installé sur '${alias}'. Pour l'installer: ssh ${alias} "sudo apt install -y tmux"` };
|
|
2593
|
+
}
|
|
2594
|
+
return { installed: true };
|
|
2595
|
+
}
|
|
480
2596
|
|
|
481
2597
|
server.registerTool(
|
|
482
|
-
"
|
|
2598
|
+
"tmux_create",
|
|
483
2599
|
{
|
|
484
|
-
title: "
|
|
485
|
-
description: "
|
|
486
|
-
inputSchema: {
|
|
2600
|
+
title: "Créer une session tmux",
|
|
2601
|
+
description: "Crée une nouvelle session tmux sur un serveur distant. La session survit aux déconnexions et aux redémarrages du MCP.",
|
|
2602
|
+
inputSchema: z.object({
|
|
487
2603
|
alias: z.string().describe("Alias du serveur cible."),
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
remote: z.string().describe("Chemin distant")
|
|
492
|
-
})).describe("Liste des fichiers à transférer"),
|
|
493
|
-
rappel: z.number().optional().describe("Définit un rappel en secondes.")
|
|
494
|
-
}
|
|
2604
|
+
name: z.string().optional().describe("Nom de la session tmux (auto-généré si omis)."),
|
|
2605
|
+
start_cmd: z.string().optional().describe("Commande initiale à lancer dans la session.")
|
|
2606
|
+
})
|
|
495
2607
|
},
|
|
496
2608
|
async (params) => {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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 }; }
|
|
2611
|
+
const tmuxCheck = await checkTmux(params.alias);
|
|
2612
|
+
if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
|
|
2613
|
+
const sessionName = params.name || `tmux-${Date.now()}`;
|
|
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 : ''}`;
|
|
2617
|
+
const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
|
|
503
2618
|
history.logTask(job);
|
|
504
|
-
|
|
505
|
-
|
|
2619
|
+
ssh.executeCommand(job.id);
|
|
506
2620
|
const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
} else {
|
|
510
|
-
return { content: [{ type: "text", text: `Tâche de transferts multiples ${job.id} initiée en arrière-plan.` }] };
|
|
511
|
-
}
|
|
2621
|
+
const output = finalJob ? finalJob.output || '' : 'Timeout';
|
|
2622
|
+
return { content: [{ type: "text", text: output.includes('error') ? `Erreur: ${output}` : `Session tmux '${sessionName}' créée sur ${params.alias}.` }] };
|
|
512
2623
|
}
|
|
513
2624
|
);
|
|
514
2625
|
|
|
515
2626
|
server.registerTool(
|
|
516
|
-
"
|
|
2627
|
+
"tmux_exec",
|
|
517
2628
|
{
|
|
518
|
-
title: "Exécuter une commande
|
|
519
|
-
description: "
|
|
520
|
-
inputSchema: {
|
|
521
|
-
alias: z.string().describe("Alias du serveur
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
responses: z.record(z.string()).optional().describe("Réponses personnalisées aux prompts (clé: pattern, valeur: réponse)."),
|
|
526
|
-
timeout: z.number().optional().describe("Timeout personnalisé en secondes. Défaut 2 minutes."),
|
|
527
|
-
rappel: z.number().optional().describe("Définit un rappel en secondes.")
|
|
528
|
-
}
|
|
2629
|
+
title: "Exécuter une commande dans une session tmux",
|
|
2630
|
+
description: "Envoie une commande à une session tmux existante.",
|
|
2631
|
+
inputSchema: z.object({
|
|
2632
|
+
alias: z.string().describe("Alias du serveur."),
|
|
2633
|
+
session: z.string().describe("Nom de la session tmux."),
|
|
2634
|
+
cmd: z.string().describe("Commande à exécuter dans la session.")
|
|
2635
|
+
})
|
|
529
2636
|
},
|
|
530
2637
|
async (params) => {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
});
|
|
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 }; }
|
|
2640
|
+
const tmuxCheck = await checkTmux(params.alias);
|
|
2641
|
+
if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
|
|
2642
|
+
const cmd = `tmux send-keys -t ${utils.escapeShellArg(params.session)} -- ${utils.escapeShellArg(params.cmd)} Enter`;
|
|
2643
|
+
const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
|
|
538
2644
|
history.logTask(job);
|
|
539
2645
|
ssh.executeCommand(job.id);
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
if (finalJob) {
|
|
543
|
-
return { content: [{ type: "text", text: `Résultat commande interactive (tâche ${finalJob.id}):\n${finalJob.output || JSON.stringify(finalJob, null, 2)}` }] };
|
|
544
|
-
} else {
|
|
545
|
-
return { content: [{ type: "text", text: `Tâche interactive ${job.id} initiée en arrière-plan.` }] };
|
|
546
|
-
}
|
|
2646
|
+
const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
2647
|
+
return { content: [{ type: "text", text: finalJob ? `Commande envoyée à la session '${params.session}'.` : buildAsyncMessage(job, "tmux exec") }] };
|
|
547
2648
|
}
|
|
548
2649
|
);
|
|
549
2650
|
|
|
550
2651
|
server.registerTool(
|
|
551
|
-
"
|
|
2652
|
+
"tmux_read",
|
|
552
2653
|
{
|
|
553
|
-
title: "
|
|
554
|
-
description: "
|
|
555
|
-
inputSchema: {
|
|
556
|
-
alias: z.string().describe("Alias du serveur
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
z.object({
|
|
560
|
-
command: z.string(),
|
|
561
|
-
timeout: z.number().optional(),
|
|
562
|
-
continueOnError: z.boolean().optional()
|
|
563
|
-
})
|
|
564
|
-
])).describe("Liste des commandes à exécuter en séquence."),
|
|
565
|
-
continueOnError: z.boolean().optional().default(false).describe("Continuer même si une commande échoue."),
|
|
566
|
-
rappel: z.number().optional().describe("Définit un rappel en secondes.")
|
|
567
|
-
}
|
|
2654
|
+
title: "Lire le buffer d'une session tmux",
|
|
2655
|
+
description: "Récupère le contenu affiché dans une session tmux.",
|
|
2656
|
+
inputSchema: z.object({
|
|
2657
|
+
alias: z.string().describe("Alias du serveur."),
|
|
2658
|
+
session: z.string().describe("Nom de la session tmux.")
|
|
2659
|
+
})
|
|
568
2660
|
},
|
|
569
2661
|
async (params) => {
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
});
|
|
2662
|
+
const tmuxCheck = await checkTmux(params.alias);
|
|
2663
|
+
if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
|
|
2664
|
+
const cmd = `tmux capture-pane -t ${utils.escapeShellArg(params.session)} -p -S -200`;
|
|
2665
|
+
const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
|
|
575
2666
|
history.logTask(job);
|
|
576
|
-
ssh.
|
|
577
|
-
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
return { content: [{ type: "text", text: `Résultat séquence (tâche ${finalJob.id}):\n${JSON.stringify(finalJob, null, 2)}` }] };
|
|
581
|
-
} else {
|
|
582
|
-
return { content: [{ type: "text", text: `Séquence de commandes ${job.id} initiée en arrière-plan.` }] };
|
|
583
|
-
}
|
|
2667
|
+
ssh.executeCommand(job.id);
|
|
2668
|
+
const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
2669
|
+
const output = finalJob ? (finalJob.output || '(vide)') : 'Timeout';
|
|
2670
|
+
return { content: [{ type: "text", text: `Buffer de la session '${params.session}':\n\n${output}` }] };
|
|
584
2671
|
}
|
|
585
2672
|
);
|
|
586
2673
|
|
|
587
2674
|
server.registerTool(
|
|
588
|
-
"
|
|
2675
|
+
"tmux_list",
|
|
589
2676
|
{
|
|
590
|
-
title: "
|
|
591
|
-
description: "
|
|
592
|
-
inputSchema: {
|
|
2677
|
+
title: "Lister les sessions tmux",
|
|
2678
|
+
description: "Liste toutes les sessions tmux actives sur un serveur.",
|
|
2679
|
+
inputSchema: z.object({
|
|
2680
|
+
alias: z.string().describe("Alias du serveur.")
|
|
2681
|
+
})
|
|
593
2682
|
},
|
|
594
|
-
async () => {
|
|
595
|
-
const
|
|
596
|
-
return { content: [{ type: "text", text:
|
|
2683
|
+
async (params) => {
|
|
2684
|
+
const tmuxCheck = await checkTmux(params.alias);
|
|
2685
|
+
if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
|
|
2686
|
+
const cmd = "tmux list-sessions 2>/dev/null || echo '(aucune session tmux)'";
|
|
2687
|
+
const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
|
|
2688
|
+
history.logTask(job);
|
|
2689
|
+
ssh.executeCommand(job.id);
|
|
2690
|
+
const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
2691
|
+
const output = finalJob ? (finalJob.output || '(vide)') : 'Timeout';
|
|
2692
|
+
return { content: [{ type: "text", text: `Sessions tmux sur ${params.alias}:\n${output}` }] };
|
|
597
2693
|
}
|
|
598
2694
|
);
|
|
599
2695
|
|
|
600
2696
|
server.registerTool(
|
|
601
|
-
"
|
|
2697
|
+
"tmux_kill",
|
|
602
2698
|
{
|
|
603
|
-
title: "
|
|
604
|
-
description: "
|
|
605
|
-
inputSchema: {
|
|
2699
|
+
title: "Tuer une session tmux",
|
|
2700
|
+
description: "Supprime une session tmux.",
|
|
2701
|
+
inputSchema: z.object({
|
|
2702
|
+
alias: z.string().describe("Alias du serveur."),
|
|
2703
|
+
session: z.string().describe("Nom de la session à tuer.")
|
|
2704
|
+
})
|
|
606
2705
|
},
|
|
607
|
-
async () => {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
};
|
|
615
|
-
|
|
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 }; }
|
|
2709
|
+
const tmuxCheck = await checkTmux(params.alias);
|
|
2710
|
+
if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
|
|
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.'`;
|
|
2713
|
+
const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
|
|
2714
|
+
history.logTask(job);
|
|
2715
|
+
ssh.executeCommand(job.id);
|
|
2716
|
+
const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
2717
|
+
const output = finalJob ? (finalJob.output || 'OK') : 'Timeout';
|
|
2718
|
+
return { content: [{ type: "text", text: output }] };
|
|
616
2719
|
}
|
|
617
2720
|
);
|
|
618
2721
|
|
|
2722
|
+
// --- OUTILS TUNNELS SSH ---
|
|
2723
|
+
|
|
619
2724
|
server.registerTool(
|
|
620
|
-
"
|
|
2725
|
+
"tunnel_create",
|
|
621
2726
|
{
|
|
622
|
-
title: "
|
|
623
|
-
description: "
|
|
624
|
-
inputSchema: {
|
|
625
|
-
|
|
626
|
-
|
|
2727
|
+
title: "Créer un tunnel SSH",
|
|
2728
|
+
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.",
|
|
2729
|
+
inputSchema: z.object({
|
|
2730
|
+
name: z.string().describe("Nom unique du tunnel (ex: 'crm-dev')."),
|
|
2731
|
+
type: z.enum(['local', 'remote', 'socks']).describe("local=-L, remote=-R, socks=-D"),
|
|
2732
|
+
listen_port: z.number().int().positive().describe("Port d'écoute (ex: 8080)."),
|
|
2733
|
+
target: z.string().optional().describe("Cible host:port (ex: '127.0.0.1:3100'). Non requis pour SOCKS."),
|
|
2734
|
+
via: z.string().describe("Serveur de transit SSH (alias)."),
|
|
2735
|
+
source: z.string().optional().describe("Où le tunnel tourne. null=local, ou alias serveur (ex: 'fkomprodmini2')."),
|
|
2736
|
+
key_path: z.string().optional().describe("Chemin de la clé SSH sur le serveur source (requis si source est distant).")
|
|
2737
|
+
})
|
|
627
2738
|
},
|
|
628
2739
|
async (params) => {
|
|
629
2740
|
try {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
sftp.executeTransfer(newJob.id);
|
|
635
|
-
} else if (newJob.type === 'ssh') {
|
|
636
|
-
ssh.executeCommand(newJob.id);
|
|
637
|
-
} else if (newJob.type === 'ssh_sequence') {
|
|
638
|
-
ssh.executeCommandSequence(newJob.id);
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
return { content: [{ type: "text", text: `Tâche ${params.id} relancée avec le nouvel ID: ${newJob.id}` }] };
|
|
2741
|
+
guardWritable('tunnel_create');
|
|
2742
|
+
if (params.source) await guardAliasWritable(params.source, 'tunnel_create');
|
|
2743
|
+
const result = await tunnels.create(params, servers);
|
|
2744
|
+
return { content: [{ type: "text", text: result }] };
|
|
642
2745
|
} catch (e) {
|
|
643
|
-
|
|
644
|
-
toolName: "task_retry",
|
|
645
|
-
errorCode: "TOOL_EXECUTION_ERROR",
|
|
646
|
-
errorMessage: e.message
|
|
647
|
-
};
|
|
648
|
-
return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
|
|
2746
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "tunnel_create", errorCode: "TUNNEL_ERROR", errorMessage: e.message }, null, 2) }], isError: true };
|
|
649
2747
|
}
|
|
650
2748
|
}
|
|
651
2749
|
);
|
|
652
2750
|
|
|
653
2751
|
server.registerTool(
|
|
654
|
-
"
|
|
2752
|
+
"tunnel_list",
|
|
655
2753
|
{
|
|
656
|
-
title: "
|
|
657
|
-
description: "
|
|
658
|
-
inputSchema: {
|
|
659
|
-
level: z.enum(['error', 'warn', 'info', 'debug']).optional().describe("Filtrer par niveau de log."),
|
|
660
|
-
search: z.string().optional().describe("Rechercher dans les messages."),
|
|
661
|
-
limit: z.number().optional().default(50).describe("Nombre de logs à afficher.")
|
|
662
|
-
}
|
|
2754
|
+
title: "Lister les tunnels",
|
|
2755
|
+
description: "Liste tous les tunnels SSH actifs et leur état.",
|
|
2756
|
+
inputSchema: z.object({})
|
|
663
2757
|
},
|
|
664
|
-
async (
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
search: params.search
|
|
668
|
-
}).slice(-params.limit);
|
|
669
|
-
return { content: [{ type: "text", text: JSON.stringify(logs, null, 2) }] };
|
|
2758
|
+
async () => {
|
|
2759
|
+
const result = await tunnels.list();
|
|
2760
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
670
2761
|
}
|
|
671
2762
|
);
|
|
672
2763
|
|
|
673
|
-
// --- NOUVEAUX OUTILS DE LOGS (v8) ---
|
|
674
|
-
|
|
675
2764
|
server.registerTool(
|
|
676
|
-
"
|
|
2765
|
+
"tunnel_close",
|
|
677
2766
|
{
|
|
678
|
-
title: "
|
|
679
|
-
description: "
|
|
680
|
-
inputSchema: {
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
lines: z.number().optional().default(100).describe("Nombre de lignes à récupérer."),
|
|
684
|
-
errors: z.boolean().optional().default(false).describe("Récupérer uniquement les erreurs (stderr).")
|
|
685
|
-
}
|
|
2767
|
+
title: "Fermer un tunnel",
|
|
2768
|
+
description: "Ferme un tunnel SSH par son nom.",
|
|
2769
|
+
inputSchema: z.object({
|
|
2770
|
+
name: z.string().describe("Nom du tunnel à fermer.")
|
|
2771
|
+
})
|
|
686
2772
|
},
|
|
687
2773
|
async (params) => {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
698
|
-
if (finalJob) {
|
|
699
|
-
return { content: [{ type: "text", text: `📋 Logs PM2${params.app ? ` (${params.app})` : ''} - ${finalJob.lineCount || 0} lignes:\n\n${finalJob.output || '(vide)'}` }] };
|
|
700
|
-
} else {
|
|
701
|
-
return { content: [{ type: "text", text: `Tâche ${job.id} initiée en arrière-plan.` }] };
|
|
2774
|
+
try {
|
|
2775
|
+
guardWritable('tunnel_close');
|
|
2776
|
+
const info = await tunnels.get(params.name);
|
|
2777
|
+
if (info?.source) await guardAliasWritable(info.source, 'tunnel_close');
|
|
2778
|
+
const result = await tunnels.close(params.name, servers);
|
|
2779
|
+
return { content: [{ type: "text", text: result }] };
|
|
2780
|
+
} catch (e) {
|
|
2781
|
+
return { content: [{ type: "text", text: JSON.stringify({ toolName: "tunnel_close", errorCode: "TUNNEL_ERROR", errorMessage: e.message }, null, 2) }], isError: true };
|
|
702
2782
|
}
|
|
703
2783
|
}
|
|
704
2784
|
);
|
|
705
2785
|
|
|
706
2786
|
server.registerTool(
|
|
707
|
-
"
|
|
2787
|
+
"tunnel_allowlist_add",
|
|
708
2788
|
{
|
|
709
|
-
title: "
|
|
710
|
-
description: "
|
|
711
|
-
inputSchema: {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
lines: z.number().optional().default(100).describe("Nombre de lignes à récupérer."),
|
|
715
|
-
since: z.string().optional().describe("Logs depuis (ex: '5m', '1h', '2024-01-01')."),
|
|
716
|
-
timestamps: z.boolean().optional().default(false).describe("Afficher les timestamps.")
|
|
717
|
-
}
|
|
2789
|
+
title: "Ajouter un port à l'allowlist des tunnels",
|
|
2790
|
+
description: "Autorise un port pour les tunnels SSH (sécurité).",
|
|
2791
|
+
inputSchema: z.object({
|
|
2792
|
+
port: z.number().int().positive().describe("Port à autoriser (ex: 3306).")
|
|
2793
|
+
})
|
|
718
2794
|
},
|
|
719
2795
|
async (params) => {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
cmd += ` ${params.container}`;
|
|
724
|
-
|
|
725
|
-
const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd: cmd, streaming: false });
|
|
726
|
-
history.logTask(job);
|
|
727
|
-
ssh.executeCommand(job.id);
|
|
728
|
-
|
|
729
|
-
const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
|
|
730
|
-
if (finalJob) {
|
|
731
|
-
return { content: [{ type: "text", text: `🐳 Logs Docker (${params.container}) - ${finalJob.lineCount || 0} lignes:\n\n${finalJob.output || '(vide)'}` }] };
|
|
732
|
-
} else {
|
|
733
|
-
return { content: [{ type: "text", text: `Tâche ${job.id} initiée en arrière-plan.` }] };
|
|
734
|
-
}
|
|
2796
|
+
guardWritable('tunnel_allowlist_add');
|
|
2797
|
+
const list = await tunnels.allowlistAdd(params.port);
|
|
2798
|
+
return { content: [{ type: "text", text: `Port ${params.port} ajouté à l'allowlist.\nPorts autorisés: ${list.join(', ')}` }] };
|
|
735
2799
|
}
|
|
736
2800
|
);
|
|
737
2801
|
|
|
738
2802
|
server.registerTool(
|
|
739
|
-
"
|
|
2803
|
+
"tunnel_allowlist_remove",
|
|
740
2804
|
{
|
|
741
|
-
title: "
|
|
742
|
-
description: "
|
|
743
|
-
inputSchema: {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
lines: z.number().optional().default(50).describe("Nombre de lignes à afficher.")
|
|
747
|
-
}
|
|
2805
|
+
title: "Retirer un port de l'allowlist des tunnels",
|
|
2806
|
+
description: "Supprime un port de la liste des ports autorisés.",
|
|
2807
|
+
inputSchema: z.object({
|
|
2808
|
+
port: z.number().int().positive().describe("Port à retirer.")
|
|
2809
|
+
})
|
|
748
2810
|
},
|
|
749
2811
|
async (params) => {
|
|
750
|
-
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
2812
|
+
guardWritable('tunnel_allowlist_remove');
|
|
2813
|
+
const list = await tunnels.allowlistRemove(params.port);
|
|
2814
|
+
return { content: [{ type: "text", text: `Port ${params.port} retiré de l'allowlist.\nPorts autorisés: ${list.join(', ')}` }] };
|
|
2815
|
+
}
|
|
2816
|
+
);
|
|
754
2817
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
2818
|
+
// --- GUIDE IA (manuel intégré) ---
|
|
2819
|
+
|
|
2820
|
+
server.registerTool(
|
|
2821
|
+
"guide",
|
|
2822
|
+
{
|
|
2823
|
+
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.",
|
|
2824
|
+
inputSchema: z.object({
|
|
2825
|
+
section: z.enum(['index', 'sources', 'file-editing', 'workflows', 'cheatsheet', 'audit', 'security', 'pitfalls']).optional().default('index').describe("Section du guide")
|
|
2826
|
+
})
|
|
2827
|
+
},
|
|
2828
|
+
async (params) => {
|
|
2829
|
+
return { content: [{ type: "text", text: guide.get(params.section) }] };
|
|
761
2830
|
}
|
|
762
2831
|
);
|
|
763
2832
|
|
|
764
2833
|
// --- DÉMARRAGE DU SERVEUR ---
|
|
765
2834
|
async function main() {
|
|
2835
|
+
if (DEBUG) console.error("🔌 Connexion du transport stdio...");
|
|
766
2836
|
const transport = new StdioServerTransport();
|
|
767
2837
|
await server.connect(transport);
|
|
2838
|
+
if (DEBUG) console.error("🚀 Serveur stdio connecté et prêt !");
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
async function shutdown() {
|
|
2842
|
+
if (DEBUG) console.error("Arrêt du serveur...");
|
|
2843
|
+
tunnels.shutdown();
|
|
2844
|
+
await queue.shutdown();
|
|
2845
|
+
sshPool.closeAll();
|
|
2846
|
+
shellSessions.closeAll();
|
|
2847
|
+
process.exit(0);
|
|
768
2848
|
}
|
|
769
2849
|
|
|
2850
|
+
process.on('SIGINT', shutdown);
|
|
2851
|
+
process.on('SIGTERM', shutdown);
|
|
2852
|
+
|
|
770
2853
|
main().catch((error) => {
|
|
771
2854
|
console.error("Erreur fatale du serveur:", error);
|
|
772
2855
|
process.exit(1);
|