@thlg057/mo5-rag-mcp 1.0.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/LICENSE +21 -0
- package/README.md +147 -0
- package/index.js +592 -0
- package/package.json +32 -0
- package/scripts/fd2sd.py +59 -0
- package/scripts/makefd.py +407 -0
- package/scripts/png2mo5.py +478 -0
package/index.js
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import {
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
CallToolRequestSchema,
|
|
8
|
+
ListResourcesRequestSchema,
|
|
9
|
+
ReadResourceRequestSchema,
|
|
10
|
+
ListPromptsRequestSchema,
|
|
11
|
+
GetPromptRequestSchema
|
|
12
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
import { spawn } from "child_process";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
import path from "path";
|
|
16
|
+
|
|
17
|
+
// Récupération de l'URL du NAS avec fallback
|
|
18
|
+
const RAG_BASE_URL = process.env.RAG_BASE_URL ?? "http://nas:8080";
|
|
19
|
+
|
|
20
|
+
// Répertoire des scripts Python (relatif à index.js)
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const SCRIPTS_DIR = path.join(__dirname, "scripts");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Exécute un script Python et retourne { stdout, stderr }
|
|
26
|
+
* Rejette avec l'erreur complète si le code de sortie est non-zéro.
|
|
27
|
+
*/
|
|
28
|
+
function runScript(scriptName, args) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const scriptPath = path.join(SCRIPTS_DIR, scriptName);
|
|
31
|
+
const child = spawn("python3", [scriptPath, ...args]);
|
|
32
|
+
|
|
33
|
+
let stdout = "";
|
|
34
|
+
let stderr = "";
|
|
35
|
+
|
|
36
|
+
child.stdout.on("data", (d) => { stdout += d; });
|
|
37
|
+
child.stderr.on("data", (d) => { stderr += d; });
|
|
38
|
+
|
|
39
|
+
child.on("close", (code) => {
|
|
40
|
+
if (code === 0) {
|
|
41
|
+
resolve({ stdout, stderr });
|
|
42
|
+
} else {
|
|
43
|
+
reject(new Error(`Exit ${code}\n${stderr || stdout}`));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
child.on("error", (err) => {
|
|
48
|
+
reject(new Error(`Impossible de lancer python3 : ${err.message}`));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Utilitaire fetch avec Timeout et Retry
|
|
55
|
+
*/
|
|
56
|
+
async function fetchWithRetry(url, options = {}, retries = 3, backoff = 1000) {
|
|
57
|
+
const { timeout = 30000, ...fetchOptions } = options;
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < retries; i++) {
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const id = setTimeout(() => controller.abort(), timeout);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch(url, {
|
|
65
|
+
...fetchOptions,
|
|
66
|
+
signal: controller.signal
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
clearTimeout(id);
|
|
70
|
+
return response;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
clearTimeout(id);
|
|
73
|
+
const isLastAttempt = i === retries - 1;
|
|
74
|
+
|
|
75
|
+
if (isLastAttempt) throw err;
|
|
76
|
+
|
|
77
|
+
const delay = backoff * Math.pow(2, i);
|
|
78
|
+
console.error(`Tentative ${i + 1} échouée, nouvel essai dans ${delay}ms...`);
|
|
79
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Recherche sémantique avec fallback automatique sur le score de similarité.
|
|
86
|
+
* Si aucun résultat à `minScore`, on retente à `fallbackScore`.
|
|
87
|
+
*/
|
|
88
|
+
async function semanticSearch({ query, tags, maxResults = 5, minScore = 0.7, fallbackScore = 0.5 }) {
|
|
89
|
+
const doSearch = async (score) => {
|
|
90
|
+
const res = await fetchWithRetry(`${RAG_BASE_URL}/api/Search`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
query,
|
|
95
|
+
tags,
|
|
96
|
+
maxResults,
|
|
97
|
+
minSimilarityScore: score,
|
|
98
|
+
includeMetadata: true,
|
|
99
|
+
}),
|
|
100
|
+
timeout: 30000
|
|
101
|
+
}, 3);
|
|
102
|
+
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
const errorText = await res.text();
|
|
105
|
+
throw new Error(`Erreur API NAS (${res.status}): ${errorText}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return res.json();
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
console.error(`DEBUG semantic_search: query="${query}", minScore=${minScore}`);
|
|
112
|
+
|
|
113
|
+
let data = await doSearch(minScore);
|
|
114
|
+
|
|
115
|
+
// Fallback si aucun résultat avec le score initial
|
|
116
|
+
if ((!data.results || data.results.length === 0) && fallbackScore < minScore) {
|
|
117
|
+
console.error(`Aucun résultat à ${minScore}, fallback à ${fallbackScore}...`);
|
|
118
|
+
data = await doSearch(fallbackScore);
|
|
119
|
+
data._usedFallback = true;
|
|
120
|
+
data._fallbackScore = fallbackScore;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return data;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Initialisation du serveur MCP
|
|
128
|
+
*/
|
|
129
|
+
const server = new Server(
|
|
130
|
+
{
|
|
131
|
+
name: "mo5-rag-mcp",
|
|
132
|
+
version: "1.0.0",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
capabilities: {
|
|
136
|
+
tools: {},
|
|
137
|
+
resources: {},
|
|
138
|
+
prompts: {},
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
/* ============================================================
|
|
144
|
+
TOOLS (Outils actionnables par l'IA)
|
|
145
|
+
============================================================ */
|
|
146
|
+
|
|
147
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
148
|
+
return {
|
|
149
|
+
tools: [
|
|
150
|
+
// --- Outils RAG ---
|
|
151
|
+
{
|
|
152
|
+
name: "semantic_search",
|
|
153
|
+
description: "Recherche sémantique dans la documentation MO5 (base de connaissances). Si aucun résultat n'est trouvé avec le score demandé, une seconde tentative est effectuée automatiquement avec un seuil plus permissif.",
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: "object",
|
|
156
|
+
properties: {
|
|
157
|
+
query: { type: "string", description: "La question ou les mots-clés de recherche" },
|
|
158
|
+
tags: { type: "array", items: { type: "string" }, description: "Filtres par thématiques" },
|
|
159
|
+
maxResults: { type: "number", default: 5 },
|
|
160
|
+
minSimilarityScore: { type: "number", default: 0.7, description: "Score minimum de similarité (0.0 à 1.0). Un fallback automatique à 0.5 est appliqué si aucun résultat n'est trouvé." },
|
|
161
|
+
},
|
|
162
|
+
required: ["query"],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "get_chunk_context",
|
|
167
|
+
description: "Récupère le contexte élargi autour d'un chunk retourné par semantic_search : les chunks voisins (avant/après) dans le même document. Utile quand une explication technique est découpée sur plusieurs chunks et que la réponse semble incomplète ou tronquée.",
|
|
168
|
+
inputSchema: {
|
|
169
|
+
type: "object",
|
|
170
|
+
properties: {
|
|
171
|
+
documentId: { type: "string", description: "L'ID du document (champ document.documentId dans les résultats de semantic_search)" },
|
|
172
|
+
chunkIndex: { type: "number", description: "L'index du chunk central (champ position.chunkIndex dans les résultats de semantic_search)" },
|
|
173
|
+
contextSize: { type: "number", default: 2, description: "Nombre de chunks à récupérer de chaque côté (défaut: 2)" },
|
|
174
|
+
},
|
|
175
|
+
required: ["documentId", "chunkIndex"],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "list_official_docs",
|
|
180
|
+
description: "Liste les documents officiels et de développement Thomson MO5 (manuels, guides techniques). Utile pour identifier quelles ressources existent avant de faire une recherche sémantique ciblée, ou pour fournir un lien de téléchargement direct à l'utilisateur.",
|
|
181
|
+
inputSchema: {
|
|
182
|
+
type: "object",
|
|
183
|
+
properties: {
|
|
184
|
+
tag: { type: "string", description: "Filtrer par tag (ex: 'basic', 'assembleur', 'hardware'). Optionnel." },
|
|
185
|
+
},
|
|
186
|
+
required: [],
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
// --- Outils de build MO5 ---
|
|
191
|
+
{
|
|
192
|
+
name: "make_fd",
|
|
193
|
+
description: "Génère une image disquette .fd bootable pour Thomson MO5 à partir d'un ou plusieurs binaires compilés avec CMOC. Le boot loader MO5 est embarqué directement dans le script — aucune dépendance externe requise. Équivalent de : fdfs -addBL output.fd BOOTMO.BIN program.BIN",
|
|
194
|
+
inputSchema: {
|
|
195
|
+
type: "object",
|
|
196
|
+
properties: {
|
|
197
|
+
output_fd: {
|
|
198
|
+
type: "string",
|
|
199
|
+
description: "Chemin de sortie de l'image disquette, ex: output/MYAPP.fd"
|
|
200
|
+
},
|
|
201
|
+
input_bins: {
|
|
202
|
+
type: "array",
|
|
203
|
+
items: { type: "string" },
|
|
204
|
+
description: "Liste des chemins vers les fichiers .BIN compilés avec CMOC (avec header Thomson)"
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
required: ["output_fd", "input_bins"]
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: "fd_to_sd",
|
|
212
|
+
description: "Convertit une image disquette .fd (format 3.5\" 720 Ko) en .sd (format 5.25\" 320 Ko) pour une utilisation avec SDDrive — le composant qui simule un lecteur de disquette sur Thomson MO5 via carte SD.",
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: "object",
|
|
215
|
+
properties: {
|
|
216
|
+
input_fd: {
|
|
217
|
+
type: "string",
|
|
218
|
+
description: "Chemin vers le fichier .fd source, ex: output/MYAPP.fd"
|
|
219
|
+
},
|
|
220
|
+
output_sd: {
|
|
221
|
+
type: "string",
|
|
222
|
+
description: "Chemin de sortie du fichier .sd, ex: output/MYAPP.sd"
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
required: ["input_fd", "output_sd"]
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: "png_to_mo5_sprite",
|
|
230
|
+
description: "Convertit une image PNG en tableaux C (FORME + COULEUR) pour le Thomson MO5. Génère un fichier .h directement utilisable avec le SDK MO5 (mo5_sprite.h). Format : 1 octet = 8 pixels, 2 couleurs par groupe de 8 pixels.",
|
|
231
|
+
inputSchema: {
|
|
232
|
+
type: "object",
|
|
233
|
+
properties: {
|
|
234
|
+
image_path: {
|
|
235
|
+
type: "string",
|
|
236
|
+
description: "Chemin vers le fichier PNG source, ex: assets/hero.png"
|
|
237
|
+
},
|
|
238
|
+
output_path: {
|
|
239
|
+
type: "string",
|
|
240
|
+
description: "Chemin de sortie du fichier .h généré, ex: include/assets/hero.h"
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
required: ["image_path", "output_path"]
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
251
|
+
const { name, arguments: args } = request.params;
|
|
252
|
+
|
|
253
|
+
// --- Tool : semantic_search ---
|
|
254
|
+
if (name === "semantic_search") {
|
|
255
|
+
try {
|
|
256
|
+
const data = await semanticSearch({
|
|
257
|
+
query: args.query,
|
|
258
|
+
tags: args.tags,
|
|
259
|
+
maxResults: args.maxResults ?? 5,
|
|
260
|
+
minScore: args.minSimilarityScore ?? 0.7,
|
|
261
|
+
fallbackScore: 0.5,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const results = data.results || [];
|
|
265
|
+
|
|
266
|
+
if (results.length === 0) {
|
|
267
|
+
return {
|
|
268
|
+
content: [{ type: "text", text: "Aucun résultat trouvé, même avec un seuil de similarité abaissé à 0.5. Essayez d'autres mots-clés ou consultez list_official_docs." }]
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const fallbackNote = data._usedFallback
|
|
273
|
+
? `⚠️ Aucun résultat au seuil demandé — résultats obtenus avec un seuil abaissé à ${data._fallbackScore} (pertinence réduite).\n\n`
|
|
274
|
+
: "";
|
|
275
|
+
|
|
276
|
+
const text = fallbackNote + results
|
|
277
|
+
.map(r =>
|
|
278
|
+
`${r.content}\n` +
|
|
279
|
+
`(Source: ${r.document?.fileName}, score: ${r.similarityScore}, ` +
|
|
280
|
+
`chunkIndex: ${r.position?.chunkIndex ?? "?"}, documentId: ${r.document?.documentId})`
|
|
281
|
+
)
|
|
282
|
+
.join("\n\n");
|
|
283
|
+
|
|
284
|
+
return { content: [{ type: "text", text }] };
|
|
285
|
+
|
|
286
|
+
} catch (error) {
|
|
287
|
+
console.error("Erreur semantic_search:", error.message);
|
|
288
|
+
return {
|
|
289
|
+
content: [{ type: "text", text: `Désolé, le serveur de recherche ne répond pas après plusieurs tentatives (Erreur: ${error.message}).` }],
|
|
290
|
+
isError: true
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// --- Tool : get_chunk_context ---
|
|
296
|
+
if (name === "get_chunk_context") {
|
|
297
|
+
try {
|
|
298
|
+
const { documentId, chunkIndex, contextSize = 2 } = args;
|
|
299
|
+
|
|
300
|
+
const res = await fetchWithRetry(`${RAG_BASE_URL}/api/Documents/${documentId}`, {
|
|
301
|
+
timeout: 15000
|
|
302
|
+
}, 3);
|
|
303
|
+
|
|
304
|
+
if (!res.ok) throw new Error(`Document introuvable (${res.status})`);
|
|
305
|
+
|
|
306
|
+
const doc = await res.json();
|
|
307
|
+
const chunks = doc.chunks ?? [];
|
|
308
|
+
|
|
309
|
+
if (chunks.length === 0) {
|
|
310
|
+
return { content: [{ type: "text", text: "Ce document ne contient aucun chunk accessible." }] };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const minIndex = Math.max(0, chunkIndex - contextSize);
|
|
314
|
+
const maxIndex = Math.min(chunks.length - 1, chunkIndex + contextSize);
|
|
315
|
+
|
|
316
|
+
const window = chunks
|
|
317
|
+
.filter(c => c.chunkIndex >= minIndex && c.chunkIndex <= maxIndex)
|
|
318
|
+
.sort((a, b) => a.chunkIndex - b.chunkIndex);
|
|
319
|
+
|
|
320
|
+
const text =
|
|
321
|
+
`Contexte élargi — document : ${doc.fileName} (chunks ${minIndex} à ${maxIndex})\n\n` +
|
|
322
|
+
window.map(c =>
|
|
323
|
+
`--- Chunk ${c.chunkIndex}` +
|
|
324
|
+
`${c.chunkIndex === chunkIndex ? " [chunk central]" : ""}` +
|
|
325
|
+
`${c.sectionHeading ? ` — ${c.sectionHeading}` : ""} ---\n${c.content}`
|
|
326
|
+
).join("\n\n");
|
|
327
|
+
|
|
328
|
+
return { content: [{ type: "text", text }] };
|
|
329
|
+
|
|
330
|
+
} catch (error) {
|
|
331
|
+
console.error("Erreur get_chunk_context:", error.message);
|
|
332
|
+
return {
|
|
333
|
+
content: [{ type: "text", text: `Impossible de récupérer le contexte du chunk (Erreur: ${error.message}).` }],
|
|
334
|
+
isError: true
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// --- Tool : list_official_docs ---
|
|
340
|
+
if (name === "list_official_docs") {
|
|
341
|
+
try {
|
|
342
|
+
const res = await fetchWithRetry(`${RAG_BASE_URL}/api/documents/official`, {
|
|
343
|
+
timeout: 15000
|
|
344
|
+
}, 3);
|
|
345
|
+
|
|
346
|
+
if (!res.ok) {
|
|
347
|
+
const errorText = await res.text();
|
|
348
|
+
throw new Error(`Erreur API NAS (${res.status}): ${errorText}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const docs = await res.json();
|
|
352
|
+
|
|
353
|
+
const filtered = args.tag
|
|
354
|
+
? docs.filter(d => d.tags?.some(t => t.toLowerCase().includes(args.tag.toLowerCase())))
|
|
355
|
+
: docs;
|
|
356
|
+
|
|
357
|
+
const text = filtered.length > 0
|
|
358
|
+
? filtered.map(d =>
|
|
359
|
+
`**${d.title ?? d.fileName}** (ID: ${d.id})\n` +
|
|
360
|
+
`Tags: ${(d.tags ?? []).join(", ") || "aucun"}\n` +
|
|
361
|
+
`Résumé: ${d.summary ?? "N/A"}\n` +
|
|
362
|
+
`📥 Télécharger: ${RAG_BASE_URL}/api/documents/official/${d.id}/file`
|
|
363
|
+
).join("\n\n")
|
|
364
|
+
: "Aucun document officiel trouvé.";
|
|
365
|
+
|
|
366
|
+
return { content: [{ type: "text", text }] };
|
|
367
|
+
|
|
368
|
+
} catch (error) {
|
|
369
|
+
console.error("Erreur list_official_docs:", error.message);
|
|
370
|
+
return {
|
|
371
|
+
content: [{ type: "text", text: `Impossible de récupérer les documents officiels (Erreur: ${error.message}).` }],
|
|
372
|
+
isError: true
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// --- Tool : make_fd ---
|
|
378
|
+
if (name === "make_fd") {
|
|
379
|
+
const { output_fd, input_bins } = args;
|
|
380
|
+
|
|
381
|
+
if (!Array.isArray(input_bins) || input_bins.length === 0) {
|
|
382
|
+
return {
|
|
383
|
+
content: [{ type: "text", text: "✗ Erreur : input_bins doit contenir au moins un fichier .BIN." }],
|
|
384
|
+
isError: true
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
const { stdout } = await runScript("makefd.py", [output_fd, ...input_bins]);
|
|
390
|
+
return {
|
|
391
|
+
content: [{ type: "text", text: stdout.trim() || `✓ Image .fd générée : ${output_fd}` }]
|
|
392
|
+
};
|
|
393
|
+
} catch (error) {
|
|
394
|
+
console.error("Erreur make_fd:", error.message);
|
|
395
|
+
return {
|
|
396
|
+
content: [{ type: "text", text: `✗ Échec génération .fd : ${error.message}` }],
|
|
397
|
+
isError: true
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// --- Tool : fd_to_sd ---
|
|
403
|
+
if (name === "fd_to_sd") {
|
|
404
|
+
const { input_fd, output_sd } = args;
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
const { stdout } = await runScript("fd2sd.py", ["-conv", input_fd, output_sd]);
|
|
408
|
+
return {
|
|
409
|
+
content: [{ type: "text", text: stdout.trim() || `✓ Conversion terminée : ${output_sd}` }]
|
|
410
|
+
};
|
|
411
|
+
} catch (error) {
|
|
412
|
+
console.error("Erreur fd_to_sd:", error.message);
|
|
413
|
+
return {
|
|
414
|
+
content: [{ type: "text", text: `✗ Échec conversion .fd → .sd : ${error.message}` }],
|
|
415
|
+
isError: true
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// --- Tool : png_to_mo5_sprite ---
|
|
421
|
+
if (name === "png_to_mo5_sprite") {
|
|
422
|
+
const { image_path, output_path } = args;
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
const { stdout } = await runScript("png2mo5.py", [
|
|
426
|
+
image_path,
|
|
427
|
+
"--name", output_path,
|
|
428
|
+
"--quiet"
|
|
429
|
+
]);
|
|
430
|
+
return {
|
|
431
|
+
content: [{ type: "text", text: stdout.trim() || `✓ Sprite généré : ${output_path}` }]
|
|
432
|
+
};
|
|
433
|
+
} catch (error) {
|
|
434
|
+
console.error("Erreur png_to_mo5_sprite:", error.message);
|
|
435
|
+
return {
|
|
436
|
+
content: [{ type: "text", text: `✗ Échec conversion PNG → sprite : ${error.message}` }],
|
|
437
|
+
isError: true
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
throw new Error(`Outil inconnu : ${name}`);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
/* ============================================================
|
|
446
|
+
RESOURCES (Données consultables)
|
|
447
|
+
============================================================ */
|
|
448
|
+
|
|
449
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
450
|
+
return {
|
|
451
|
+
resources: [
|
|
452
|
+
{ uri: "documents://list", name: "Liste des documents", description: "Liste tous les fichiers indexés (chunks)" },
|
|
453
|
+
{ uri: "documents://tags", name: "Tags", description: "Liste des thématiques disponibles" },
|
|
454
|
+
{ uri: "documents://official", name: "Documentation officielle MO5", description: "Manuels, guides et docs de développement officiels Thomson MO5" },
|
|
455
|
+
{ uri: "ingestion://status", name: "Statut", description: "État de santé de l'indexation RAG" },
|
|
456
|
+
],
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
461
|
+
const uri = request.params.uri;
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
let text = "";
|
|
465
|
+
|
|
466
|
+
if (uri === "documents://list") {
|
|
467
|
+
const res = await fetch(`${RAG_BASE_URL}/api/Documents`);
|
|
468
|
+
const docs = await res.json();
|
|
469
|
+
text = docs.map((d) => `- ${d.fileName} (ID: ${d.id})`).join("\n");
|
|
470
|
+
}
|
|
471
|
+
else if (uri === "documents://tags") {
|
|
472
|
+
const res = await fetch(`${RAG_BASE_URL}/api/Documents/tags`);
|
|
473
|
+
const tags = await res.json();
|
|
474
|
+
text = tags.map((t) => t.name).join(", ");
|
|
475
|
+
}
|
|
476
|
+
else if (uri === "documents://official") {
|
|
477
|
+
const res = await fetch(`${RAG_BASE_URL}/api/documents/official`);
|
|
478
|
+
const docs = await res.json();
|
|
479
|
+
text = docs.map(d =>
|
|
480
|
+
`## ${d.title ?? d.fileName}\n` +
|
|
481
|
+
`- ID: ${d.id}\n` +
|
|
482
|
+
`- Tags: ${(d.tags ?? []).join(", ") || "aucun"}\n` +
|
|
483
|
+
`- Résumé: ${d.summary ?? "N/A"}\n` +
|
|
484
|
+
`- Modifié: ${d.lastModified}\n` +
|
|
485
|
+
`- 📥 Télécharger: ${RAG_BASE_URL}/api/documents/official/${d.id}/file`
|
|
486
|
+
).join("\n\n");
|
|
487
|
+
}
|
|
488
|
+
else if (uri.startsWith("documents://official/")) {
|
|
489
|
+
const id = uri.replace("documents://official/", "");
|
|
490
|
+
const res = await fetch(`${RAG_BASE_URL}/api/documents/official/${id}`);
|
|
491
|
+
if (!res.ok) throw new Error("Document officiel introuvable");
|
|
492
|
+
const doc = await res.json();
|
|
493
|
+
text =
|
|
494
|
+
`# ${doc.title ?? doc.fileName}\n\n` +
|
|
495
|
+
`${doc.summary ?? ""}\n\n` +
|
|
496
|
+
`Tags: ${(doc.tags ?? []).join(", ") || "aucun"}\n` +
|
|
497
|
+
`Fichier: ${doc.fileName}\n` +
|
|
498
|
+
`Modifié: ${doc.lastModified}\n` +
|
|
499
|
+
`📥 Télécharger: ${RAG_BASE_URL}/api/documents/official/${doc.id}/file`;
|
|
500
|
+
}
|
|
501
|
+
else if (uri === "ingestion://status") {
|
|
502
|
+
const res = await fetch(`${RAG_BASE_URL}/api/Index/status`);
|
|
503
|
+
const status = await res.json();
|
|
504
|
+
text = JSON.stringify(status, null, 2);
|
|
505
|
+
}
|
|
506
|
+
else if (uri.startsWith("documents://")) {
|
|
507
|
+
const id = uri.replace("documents://", "");
|
|
508
|
+
const res = await fetch(`${RAG_BASE_URL}/api/Documents/${id}`);
|
|
509
|
+
if (!res.ok) throw new Error("Document introuvable");
|
|
510
|
+
const doc = await res.json();
|
|
511
|
+
text = `# ${doc.title}\n\n${doc.content}\n\nSource: ${doc.fileName}`;
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
throw new Error("Ressource inconnue");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
contents: [
|
|
519
|
+
{
|
|
520
|
+
uri: uri,
|
|
521
|
+
mimeType: "text/plain",
|
|
522
|
+
text: text
|
|
523
|
+
}
|
|
524
|
+
]
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
} catch (error) {
|
|
528
|
+
throw new Error(`Erreur ressource (${uri}) : ${error.message}`);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
/* ============================================================
|
|
533
|
+
PROMPTS (Modèles de conversation)
|
|
534
|
+
============================================================ */
|
|
535
|
+
|
|
536
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
537
|
+
return {
|
|
538
|
+
prompts: [
|
|
539
|
+
{
|
|
540
|
+
name: "mo5_expert",
|
|
541
|
+
description: "Assistant expert Thomson MO5 utilisant la base documentaire.",
|
|
542
|
+
},
|
|
543
|
+
],
|
|
544
|
+
};
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
548
|
+
if (request.params.name !== "mo5_expert") {
|
|
549
|
+
throw new Error("Prompt inconnu");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
description: "Expert MO5",
|
|
554
|
+
messages: [
|
|
555
|
+
{
|
|
556
|
+
role: "user",
|
|
557
|
+
content: {
|
|
558
|
+
type: "text",
|
|
559
|
+
text: `Tu es un expert du micro-ordinateur Thomson MO5.
|
|
560
|
+
Tu as accès aux outils suivants, à utiliser dans cet ordre selon le besoin :
|
|
561
|
+
|
|
562
|
+
**Documentation (RAG)**
|
|
563
|
+
1. **list_official_docs** : commence par cet outil quand l'utilisateur cherche une référence précise (manuel BASIC, doc du 6809, format disquette...). Il liste les documents officiels avec résumés, tags et liens de téléchargement.
|
|
564
|
+
2. **semantic_search** : recherche sémantique dans les chunks indexés. Utilise les tags identifiés via list_official_docs pour affiner. Un fallback automatique est appliqué si aucun résultat n'est trouvé au seuil demandé.
|
|
565
|
+
3. **get_chunk_context** : si un résultat de semantic_search semble incomplet ou tronqué, utilise cet outil pour récupérer les chunks voisins du même document (documentId + chunkIndex sont fournis dans chaque résultat).
|
|
566
|
+
|
|
567
|
+
**Build & outils MO5**
|
|
568
|
+
4. **make_fd** : génère une image disquette .fd bootable à partir d'un ou plusieurs .BIN compilés avec CMOC. Utilise-le après la compilation pour créer l'image disquette.
|
|
569
|
+
5. **fd_to_sd** : convertit le .fd en .sd pour SDDrive. Utilise-le après make_fd si le fichier est destiné à une carte SD.
|
|
570
|
+
6. **png_to_mo5_sprite** : convertit un PNG en fichier .h C (tableaux FORME + COULEUR) pour afficher des sprites avec le SDK MO5.
|
|
571
|
+
|
|
572
|
+
Cite toujours tes sources (fileName, score de similarité) et propose les liens de téléchargement quand c'est pertinent.`
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
]
|
|
576
|
+
};
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
/* ============================================================
|
|
580
|
+
DÉMARRAGE DU SERVEUR
|
|
581
|
+
============================================================ */
|
|
582
|
+
|
|
583
|
+
async function main() {
|
|
584
|
+
const transport = new StdioServerTransport();
|
|
585
|
+
await server.connect(transport);
|
|
586
|
+
console.error("Serveur MCP MO5-RAG démarré sur STDIO");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
main().catch((error) => {
|
|
590
|
+
console.error("Erreur fatale au démarrage:", error);
|
|
591
|
+
process.exit(1);
|
|
592
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thlg057/mo5-rag-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Serveur MCP pour le rétrocomputing Thomson MO5 (RAG et outils de build)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mo5-rag-mcp": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"scripts/",
|
|
12
|
+
"package.json"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/thlg057/mo5-mcp-server.git"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"mcp",
|
|
20
|
+
"mo5",
|
|
21
|
+
"thomson",
|
|
22
|
+
"retrocomputing",
|
|
23
|
+
"rag",
|
|
24
|
+
"cmoc",
|
|
25
|
+
"motorola-6809"
|
|
26
|
+
],
|
|
27
|
+
"author": "Thierry (thlg057)",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/scripts/fd2sd.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Convertit une image disquette .fd (3.5" 720Ko) en .sd (5.25" 320Ko)
|
|
4
|
+
pour Thomson MO5/TO7
|
|
5
|
+
Basé sur le code C d'OlivierP-To8
|
|
6
|
+
https://github.com/OlivierP-To8/InufutoPorts/blob/main/Thomson/fdtosd.c
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
def fd_to_sd(fd_path, sd_path):
|
|
13
|
+
"""Convertit un fichier .fd en .sd"""
|
|
14
|
+
|
|
15
|
+
sector_buffer = bytearray(256)
|
|
16
|
+
empty = bytes([0xFF] * 512)
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
with open(fd_path, 'rb') as fd, open(sd_path, 'wb') as sd:
|
|
20
|
+
# 4 faces * 80 pistes = 320 pistes
|
|
21
|
+
for track in range(4 * 80):
|
|
22
|
+
# 16 secteurs par piste
|
|
23
|
+
for sector in range(1, 17):
|
|
24
|
+
data = fd.read(256)
|
|
25
|
+
if len(data) == 256:
|
|
26
|
+
# Écrire le secteur + padding vide
|
|
27
|
+
sd.write(data)
|
|
28
|
+
sd.write(empty[:256])
|
|
29
|
+
else:
|
|
30
|
+
# Secteur manquant = remplir de 0xFF
|
|
31
|
+
sd.write(empty)
|
|
32
|
+
|
|
33
|
+
print(f"✓ Conversion réussie: {sd_path}")
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
except FileNotFoundError:
|
|
37
|
+
print(f"✗ Erreur: impossible d'ouvrir {fd_path}")
|
|
38
|
+
return False
|
|
39
|
+
except Exception as e:
|
|
40
|
+
print(f"✗ Erreur lors de la conversion: {e}")
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
def main():
|
|
44
|
+
if len(sys.argv) != 4 or sys.argv[1] != '-conv':
|
|
45
|
+
print(f"Usage: {sys.argv[0]} -conv disk.fd disk.sd")
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
|
|
48
|
+
fd_path = sys.argv[2]
|
|
49
|
+
sd_path = sys.argv[3]
|
|
50
|
+
|
|
51
|
+
if not os.path.exists(fd_path):
|
|
52
|
+
print(f"✗ Erreur: le fichier {fd_path} n'existe pas")
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
success = fd_to_sd(fd_path, sd_path)
|
|
56
|
+
sys.exit(0 if success else 1)
|
|
57
|
+
|
|
58
|
+
if __name__ == '__main__':
|
|
59
|
+
main()
|