@imenam/mcp-documentor 1.0.72 → 1.0.76

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.
Files changed (74) hide show
  1. package/README.md +271 -259
  2. package/dist/shared/schemas/doc.schema.d.ts +1 -4
  3. package/dist/shared/schemas/doc.schema.js +1 -4
  4. package/dist/src/cli/setup-mcp.d.ts +20 -1
  5. package/dist/src/cli/setup-mcp.js +65 -6
  6. package/dist/src/core/conflict-inspector.d.ts +13 -0
  7. package/dist/src/core/conflict-inspector.js +17 -0
  8. package/dist/src/core/git-sync.d.ts +11 -4
  9. package/dist/src/core/git-sync.js +33 -22
  10. package/dist/src/core/storage-engine.d.ts +14 -15
  11. package/dist/src/core/storage-engine.js +15 -51
  12. package/dist/src/gui-worker.js +91 -34
  13. package/dist/src/index.js +100 -110
  14. package/dist/src/tools/check-links.js +1 -1
  15. package/dist/src/tools/conflicts.js +50 -5
  16. package/dist/src/tools/list-projects.js +7 -5
  17. package/dist/src/tools/sync.js +14 -7
  18. package/dist/src/version.d.ts +1 -1
  19. package/dist/src/version.js +1 -1
  20. package/gui/dist/assets/{arc-KWKRz6LO.js → arc-CsM5OJTM.js} +1 -1
  21. package/gui/dist/assets/{architectureDiagram-3BPJPVTR-Cjfl98ZS.js → architectureDiagram-3BPJPVTR-BwIu1llT.js} +1 -1
  22. package/gui/dist/assets/{blockDiagram-GPEHLZMM-CtPUrnse.js → blockDiagram-GPEHLZMM-BVijVnPI.js} +1 -1
  23. package/gui/dist/assets/{c4Diagram-AAUBKEIU-Dh5_7IZy.js → c4Diagram-AAUBKEIU-D-rcZUZu.js} +1 -1
  24. package/gui/dist/assets/channel-DKPwwuQf.js +1 -0
  25. package/gui/dist/assets/{chunk-2J33WTMH-44T5JbXy.js → chunk-2J33WTMH-ByfTGREe.js} +1 -1
  26. package/gui/dist/assets/{chunk-4BX2VUAB-D1fEQ0Ng.js → chunk-4BX2VUAB-B_5oCXyz.js} +1 -1
  27. package/gui/dist/assets/{chunk-55IACEB6-M2lEB2Vn.js → chunk-55IACEB6-Bc2Kq9HD.js} +1 -1
  28. package/gui/dist/assets/{chunk-727SXJPM-B3A_Fp4f.js → chunk-727SXJPM-CVwmKEMQ.js} +1 -1
  29. package/gui/dist/assets/{chunk-AQP2D5EJ-CqkJXv3c.js → chunk-AQP2D5EJ-BkMuEFxD.js} +1 -1
  30. package/gui/dist/assets/{chunk-FMBD7UC4-Iyj_Ky8e.js → chunk-FMBD7UC4-CU7EDl9I.js} +1 -1
  31. package/gui/dist/assets/{chunk-ND2GUHAM-DE5R-C9U.js → chunk-ND2GUHAM-1b34Dtkn.js} +1 -1
  32. package/gui/dist/assets/{chunk-QZHKN3VN-BekBU-YP.js → chunk-QZHKN3VN-DnUfMecI.js} +1 -1
  33. package/gui/dist/assets/classDiagram-4FO5ZUOK-CnCvVGW9.js +1 -0
  34. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-CnCvVGW9.js +1 -0
  35. package/gui/dist/assets/{cose-bilkent-S5V4N54A-Dw2zOlxr.js → cose-bilkent-S5V4N54A-D9OBrxKr.js} +1 -1
  36. package/gui/dist/assets/{dagre-BM42HDAG-ebVZNyxI.js → dagre-BM42HDAG-zDqwL8xs.js} +1 -1
  37. package/gui/dist/assets/{diagram-2AECGRRQ-Bh5FUKy0.js → diagram-2AECGRRQ-ejbP8WIj.js} +1 -1
  38. package/gui/dist/assets/{diagram-5GNKFQAL-YJJTJy-Q.js → diagram-5GNKFQAL-Cv9WmtFO.js} +1 -1
  39. package/gui/dist/assets/{diagram-KO2AKTUF-D161kBvB.js → diagram-KO2AKTUF-BI0ybJiN.js} +1 -1
  40. package/gui/dist/assets/{diagram-LMA3HP47-Drcmx9J8.js → diagram-LMA3HP47-DlVGttLq.js} +1 -1
  41. package/gui/dist/assets/{diagram-OG6HWLK6-DRrxnW_H.js → diagram-OG6HWLK6-DjoAH-Fq.js} +1 -1
  42. package/gui/dist/assets/{erDiagram-TEJ5UH35-FrH4iEa3.js → erDiagram-TEJ5UH35-BVQHVGK4.js} +1 -1
  43. package/gui/dist/assets/{flowDiagram-I6XJVG4X-BMxSElvH.js → flowDiagram-I6XJVG4X-oZP2Z-6T.js} +1 -1
  44. package/gui/dist/assets/{ganttDiagram-6RSMTGT7-H09FGGUr.js → ganttDiagram-6RSMTGT7-dy4QyEOP.js} +1 -1
  45. package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-DLhPfXNp.js → gitGraphDiagram-PVQCEYII-BxgLy7Ln.js} +1 -1
  46. package/gui/dist/assets/{index-CdGsMXYw.js → index-Dtv0slFQ.js} +97 -97
  47. package/gui/dist/assets/index-QkCDSQ2A.css +1 -0
  48. package/gui/dist/assets/{infoDiagram-5YYISTIA-CV8ctX8t.js → infoDiagram-5YYISTIA-DNOdinpR.js} +1 -1
  49. package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-BeuHUSPO.js → ishikawaDiagram-YF4QCWOH-C4pQ434G.js} +1 -1
  50. package/gui/dist/assets/{journeyDiagram-JHISSGLW-CfAHwYXQ.js → journeyDiagram-JHISSGLW-CBrVYW94.js} +1 -1
  51. package/gui/dist/assets/{kanban-definition-UN3LZRKU-CFF4KWbn.js → kanban-definition-UN3LZRKU-Df-rgiY8.js} +1 -1
  52. package/gui/dist/assets/{linear-DwZpGeRp.js → linear-B_6fRXVt.js} +1 -1
  53. package/gui/dist/assets/{mindmap-definition-RKZ34NQL-BW0yW4ap.js → mindmap-definition-RKZ34NQL-CRAd_VDM.js} +1 -1
  54. package/gui/dist/assets/{pieDiagram-4H26LBE5-plBCN_c7.js → pieDiagram-4H26LBE5-CJkmLrDI.js} +1 -1
  55. package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-Dmp9sOq_.js → quadrantDiagram-W4KKPZXB-D-rDEcnF.js} +1 -1
  56. package/gui/dist/assets/{requirementDiagram-4Y6WPE33-CthKC15J.js → requirementDiagram-4Y6WPE33-DE8MhSMe.js} +1 -1
  57. package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-BCAzzfPU.js → sankeyDiagram-5OEKKPKP-BwlxtrUs.js} +1 -1
  58. package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-DgwOe82k.js → sequenceDiagram-3UESZ5HK-B4LjBzWg.js} +1 -1
  59. package/gui/dist/assets/{stateDiagram-AJRCARHV-B6k-x1TC.js → stateDiagram-AJRCARHV-Bv72afPK.js} +1 -1
  60. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-BKJDOkkR.js +1 -0
  61. package/gui/dist/assets/{timeline-definition-PNZ67QCA-CqwqJkZw.js → timeline-definition-PNZ67QCA-CBxgf8iW.js} +1 -1
  62. package/gui/dist/assets/{vennDiagram-CIIHVFJN-REFDrI5t.js → vennDiagram-CIIHVFJN-DPSdK06q.js} +1 -1
  63. package/gui/dist/assets/{wardley-L42UT6IY-53gLZWfR.js → wardley-L42UT6IY-JIAzuoPm.js} +1 -1
  64. package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-OPfKpNx-.js → wardleyDiagram-YWT4CUSO-YDfaF1wT.js} +1 -1
  65. package/gui/dist/assets/{xychartDiagram-2RQKCTM6-HWGySgFg.js → xychartDiagram-2RQKCTM6-DHlPryZX.js} +1 -1
  66. package/gui/dist/index.html +2 -2
  67. package/package.json +65 -65
  68. package/dist/src/tools/set-project.d.ts +0 -14
  69. package/dist/src/tools/set-project.js +0 -18
  70. package/gui/dist/assets/channel-JejxY436.js +0 -1
  71. package/gui/dist/assets/classDiagram-4FO5ZUOK-CdAqI_QA.js +0 -1
  72. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-CdAqI_QA.js +0 -1
  73. package/gui/dist/assets/index-DAzSbGj_.css +0 -1
  74. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-bfw3SR0t.js +0 -1
@@ -32,6 +32,22 @@ const dataDir = process.env.MCP_DATA_DIR?.trim() || path.join(rootDir, ".docs-da
32
32
  const staticRoot = path.join(rootDir, "gui/dist");
33
33
  const absoluteStaticRoot = path.resolve(staticRoot);
34
34
  const configuredAppPath = process.env.APP_PATH || "/documentor-gui";
35
+ function resolveGuiMode() {
36
+ const proxyUrl = process.env.PROXY_URL?.trim();
37
+ if (proxyUrl)
38
+ return { kind: "proxy", proxyUrl };
39
+ const rawPort = process.env.APP_PORT?.trim();
40
+ if (!rawPort) {
41
+ return { kind: "disabled", reason: "PROXY_URL et APP_PORT non définis" };
42
+ }
43
+ const port = Number(rawPort);
44
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
45
+ return { kind: "disabled", reason: `APP_PORT invalide (${rawPort}) — attendu : entier entre 1 et 65535` };
46
+ }
47
+ return { kind: "standalone", port };
48
+ }
49
+ const guiMode = resolveGuiMode();
50
+ const isStandalone = guiMode.kind === "standalone";
35
51
  setupLogging({ processLabel: "mcp-documentor:GUI", logDir: resolveLogDir(rootDir) });
36
52
  const logger = createLogger("mcp-documentor:GUI");
37
53
  process.on("uncaughtException", (err) => {
@@ -208,7 +224,7 @@ app.get("/proxy/health", (c) => {
208
224
  const memory = process.memoryUsage();
209
225
  return c.json({
210
226
  status: "ok",
211
- proxy: proxyClient ? proxyClient.getStatus() : process.env.PROXY_URL ? "error" : "standalone",
227
+ proxy: proxyClient ? proxyClient.getStatus() : isStandalone ? "standalone" : "error",
212
228
  uptime: process.uptime(),
213
229
  memory: { rss: memory.rss, heapUsed: memory.heapUsed },
214
230
  ipc: process.connected ? "connected" : "disconnected",
@@ -343,22 +359,29 @@ app.get("/api/projects", async (c) => {
343
359
  });
344
360
  app.get("/api/folders", async (c) => {
345
361
  const project = c.req.query("project");
346
- const result = await ipcRequest("LIST_FOLDERS", project ? { project } : {});
362
+ if (!project)
363
+ return c.json({ error: "project query param is required" }, 400);
364
+ const result = await ipcRequest("LIST_FOLDERS", { project });
347
365
  if (result.error)
348
366
  return c.json({ error: result.error }, 500);
349
367
  return c.json(result.data);
350
368
  });
351
- app.put("/api/project", async (c) => {
369
+ // Creates an empty project. Selecting a project is a client-side concern:
370
+ // the server holds no active project.
371
+ app.post("/api/projects", async (c) => {
352
372
  const { project } = await c.req.json();
353
373
  if (!project)
354
374
  return c.json({ error: "project is required" }, 400);
355
- const result = await ipcRequest("UPDATE_PROJECT", { project });
375
+ const result = await ipcRequest("CREATE_PROJECT", { project });
356
376
  if (result.error)
357
377
  return c.json({ error: result.error }, 500);
358
378
  return c.json(result.data);
359
379
  });
360
380
  app.get("/api/docs", async (c) => {
361
- const result = await ipcRequest("LIST_DOCS");
381
+ const project = c.req.query("project");
382
+ if (!project)
383
+ return c.json({ error: "project query param is required" }, 400);
384
+ const result = await ipcRequest("LIST_DOCS", { project });
362
385
  if (result.error)
363
386
  return c.json({ error: result.error }, 500);
364
387
  return c.json(result.data);
@@ -475,10 +498,13 @@ app.get("/api/history-version/*", async (c) => {
475
498
  return c.json(result.data);
476
499
  });
477
500
  app.post("/api/search", async (c) => {
478
- const { query, top_k, all_projects } = await c.req.json();
501
+ const { query, top_k, all_projects, project } = await c.req.json();
479
502
  if (!query)
480
503
  return c.json({ error: "query is required" }, 400);
481
- const result = await ipcRequest("SEARCH_DOCS", { query, top_k: top_k || 5, all_projects: !!all_projects });
504
+ if (!all_projects && !project) {
505
+ return c.json({ error: "project is required unless all_projects is set" }, 400);
506
+ }
507
+ const result = await ipcRequest("SEARCH_DOCS", { query, top_k: top_k || 5, all_projects: !!all_projects, project });
482
508
  if (result.error)
483
509
  return c.json({ error: result.error }, 500);
484
510
  return c.json(result.data);
@@ -522,14 +548,9 @@ app.get("/api/export-zip/:id", async (c) => {
522
548
  const bundle = bundles.find((b) => b.id === id);
523
549
  if (!bundle)
524
550
  return c.json({ error: "Bundle not found" }, 404);
525
- let currentProject = "default";
526
- try {
527
- const configPath = path.join(dataDir, "config.json");
528
- if (fs.existsSync(configPath)) {
529
- currentProject = JSON.parse(fs.readFileSync(configPath, "utf-8")).currentProject ?? "default";
530
- }
531
- }
532
- catch { /* use default */ }
551
+ // Legacy bundles may hold bare doc ids; they are resolved against the project
552
+ // the caller is viewing. Fully qualified "project:doc-id" refs ignore it.
553
+ const fallbackProject = c.req.query("project");
533
554
  const flat = c.req.query('flat') === '1';
534
555
  const includeHeaders = c.req.query('headers') === '1';
535
556
  const zip = new JSZip();
@@ -541,10 +562,15 @@ app.get("/api/export-zip/:id", async (c) => {
541
562
  project = ref.slice(0, colonIdx);
542
563
  docId = ref.slice(colonIdx + 1);
543
564
  }
544
- else {
545
- project = currentProject;
565
+ else if (fallbackProject) {
566
+ project = fallbackProject;
546
567
  docId = ref;
547
568
  }
569
+ else {
570
+ zip.file(`${ref}.md`, `# [Unqualified doc reference: ${ref} — no project given]
571
+ `);
572
+ continue;
573
+ }
548
574
  const docFilePath = path.join(dataDir, "projects", project, ...docId.split("/")) + ".md";
549
575
  let content;
550
576
  if (!fs.existsSync(docFilePath)) {
@@ -704,8 +730,9 @@ app.get("/*", (c, next) => {
704
730
  const indexPath = path.join(absoluteStaticRoot, "index.html");
705
731
  if (fs.existsSync(indexPath)) {
706
732
  // ?__pdf=1 : accès direct depuis Puppeteer — pas de préfixe proxy dans <base href>
733
+ // En mode standalone la GUI est déjà servie à la racine : même règle.
707
734
  const isPdfRender = c.req.query('__pdf') === '1';
708
- const baseHref = isPdfRender ? '/' : getHtmlBaseHref(configuredAppPath);
735
+ const baseHref = isStandalone || isPdfRender ? '/' : getHtmlBaseHref(configuredAppPath);
709
736
  const html = fs
710
737
  .readFileSync(indexPath, "utf8")
711
738
  .replace("<head>", `<head>\n <base href="${baseHref}" />`);
@@ -717,12 +744,54 @@ app.get("/*", (c, next) => {
717
744
  return next();
718
745
  }
719
746
  });
747
+ /**
748
+ * Lance Hono sur `port` et prévient le master. `publicUrl` est l'URL que
749
+ * l'utilisateur doit ouvrir : via le proxy, ou directement en localhost.
750
+ */
751
+ function listen(port, publicUrl, isProxied) {
752
+ logger.info(`Starting Hono server on port ${port}...`);
753
+ const serverInstance = serve({ fetch: app.fetch, port, hostname: "0.0.0.0" }, (info) => {
754
+ logger.info(`Dashboard server listening on port ${info.port}`);
755
+ safeSend(IPCMessageSchema.parse({
756
+ type: "READY",
757
+ data: { port: info.port, isProxied, url: publicUrl },
758
+ timestamp: new Date().toISOString(),
759
+ }));
760
+ });
761
+ serverInstance.on("error", (err) => {
762
+ if (err.code === "EADDRINUSE") {
763
+ // Sortie propre (code 0) : le master ne doit pas relancer un worker de plus.
764
+ logger.error(`Port ${port} already in use — another GUI instance is running.`);
765
+ safeSend(IPCMessageSchema.parse({
766
+ type: "ALREADY_RUNNING",
767
+ data: { url: publicUrl },
768
+ timestamp: new Date().toISOString(),
769
+ }));
770
+ process.exit(0);
771
+ }
772
+ logger.error(`Server error: ${err.message}`);
773
+ process.exit(1);
774
+ });
775
+ }
776
+ /** Sans proxy : la GUI écoute sur APP_PORT et est servie à la racine. */
777
+ function startStandalone(port) {
778
+ const publicUrl = `http://localhost:${port}`;
779
+ logger.info(`Standalone mode (no PROXY_URL): serving GUI on ${publicUrl}`);
780
+ localPort = port;
781
+ guiPublicUrl = publicUrl;
782
+ setupIpcErrorHandlers();
783
+ listen(port, publicUrl, false);
784
+ }
720
785
  async function startServer() {
721
- if (!process.env.PROXY_URL) {
722
- process.stderr.write('[GUI] PROXY_URL non défini — arrêt.\n');
786
+ if (guiMode.kind === "disabled") {
787
+ process.stderr.write(`[GUI] ${guiMode.reason} — arrêt.\n`);
723
788
  process.exit(0);
724
789
  }
725
- const proxyUrl = process.env.PROXY_URL;
790
+ if (guiMode.kind === "standalone") {
791
+ startStandalone(guiMode.port);
792
+ return;
793
+ }
794
+ const proxyUrl = guiMode.proxyUrl;
726
795
  const appPath = configuredAppPath;
727
796
  const appName = process.env.APP_NAME || "MCP Documentor GUI";
728
797
  logger.info(`Starting server with proxyUrl=${proxyUrl}`);
@@ -769,19 +838,7 @@ async function startServer() {
769
838
  process.on("SIGTERM", cleanupProxy);
770
839
  process.on("SIGINT", cleanupProxy);
771
840
  setupIpcErrorHandlers();
772
- logger.info(`Starting Hono server on port ${finalPort}...`);
773
- const serverInstance = serve({ fetch: app.fetch, port: finalPort, hostname: "0.0.0.0" }, (info) => {
774
- logger.info(`Dashboard server listening on port ${info.port}`);
775
- safeSend(IPCMessageSchema.parse({
776
- type: "READY",
777
- data: { port: info.port, isProxied: true, url: publicUrl },
778
- timestamp: new Date().toISOString(),
779
- }));
780
- });
781
- serverInstance.on("error", (err) => {
782
- logger.error(`Server error: ${err.message}`);
783
- process.exit(1);
784
- });
841
+ listen(finalPort, publicUrl, true);
785
842
  }
786
843
  startServer().catch((err) => {
787
844
  logger.error(`Failed to start GUI Worker: ${err.message}`);
package/dist/src/index.js CHANGED
@@ -20,7 +20,6 @@ import { searchDocs } from "./tools/search-docs.js";
20
20
  import { checkFreshness, computeFreshness } from "./tools/check-freshness.js";
21
21
  import { findAndHashSection } from "./core/section-tracker.js";
22
22
  import { toAbsolutePath } from "./core/path-resolver.js";
23
- import { setProject } from "./tools/set-project.js";
24
23
  import { listProjects } from "./tools/list-projects.js";
25
24
  import { refreshFileSnapshot } from "./tools/refresh-file-snapshot.js";
26
25
  import { getDocMetadata } from "./tools/get-doc-metadata.js";
@@ -50,8 +49,14 @@ const __dirname = path.dirname(__filename);
50
49
  // Les flags CLI sont traités avant toute initialisation (storage, sqlite, logs) :
51
50
  // ils s'exécutent dans le répertoire de l'utilisateur, pas dans un serveur MCP.
52
51
  if (process.argv.slice(2).includes("--claude-setup-mcp")) {
53
- const { runSetupMcp } = await import("./cli/setup-mcp.js");
54
- process.exit(runSetupMcp({ cwd: process.cwd(), force: process.argv.slice(2).includes("--force") }));
52
+ const argv = process.argv.slice(2);
53
+ const { runSetupMcp, parseGuiExposure } = await import("./cli/setup-mcp.js");
54
+ const parsed = parseGuiExposure(argv);
55
+ if ("error" in parsed) {
56
+ console.error(`Erreur : ${parsed.error}`);
57
+ process.exit(1);
58
+ }
59
+ process.exit(runSetupMcp({ cwd: process.cwd(), force: argv.includes("--force"), exposure: parsed.exposure }));
55
60
  }
56
61
  function findProjectRoot(startDir) {
57
62
  let current = startDir;
@@ -73,6 +78,7 @@ function getConfig() {
73
78
  }
74
79
  return {
75
80
  proxyUrl: process.env.PROXY_URL,
81
+ appPort: process.env.APP_PORT,
76
82
  version: APP_VERSION,
77
83
  };
78
84
  }
@@ -80,7 +86,9 @@ getConfig();
80
86
  const storageEngine = new StorageEngine(rootDir);
81
87
  const embeddingStore = new EmbeddingStore();
82
88
  const embeddingEngine = new EmbeddingEngine(embeddingStore);
83
- storageEngine.ensureProject(storageEngine.getConfig().currentProject);
89
+ for (const project of storageEngine.getConfig().projects) {
90
+ storageEngine.ensureProject(project);
91
+ }
84
92
  const openaiModel = process.env.MCP_DOCUMENTOR_OPENAI_MODEL || "text-embedding-3-small";
85
93
  embeddingStore.init(storageEngine.getDataDir(), openaiModel);
86
94
  const historyStore = new HistoryStore();
@@ -123,8 +131,8 @@ function handleIPCMessage(msg) {
123
131
  else if (msg.type === "GET_CONFIG") {
124
132
  handleGetConfig(msg);
125
133
  }
126
- else if (msg.type === "UPDATE_PROJECT") {
127
- handleUpdateProject(msg);
134
+ else if (msg.type === "CREATE_PROJECT") {
135
+ handleCreateProject(msg);
128
136
  }
129
137
  else if (msg.type === "LIST_PROJECTS") {
130
138
  handleListProjects(msg);
@@ -188,25 +196,45 @@ function requireProjectFromIPC(msg) {
188
196
  throw new Error("project is required for this operation");
189
197
  return project;
190
198
  }
199
+ /** Loads one project's summaries and flags each entry as stale/broken. */
200
+ async function listDocsWithFreshness(project) {
201
+ const docs = await storageEngine.listDocs(project);
202
+ for (const doc of docs) {
203
+ const full = await storageEngine.getDoc(doc.id, project);
204
+ if (full && full.tracked_files.length > 0) {
205
+ const report = computeFreshness(full.tracked_files, project);
206
+ doc.isStale = report.status === "stale" || report.status === "broken";
207
+ doc.isBroken = report.status === "broken";
208
+ }
209
+ }
210
+ return docs;
211
+ }
212
+ /**
213
+ * Tells every connected GUI that one project's docs changed. Clients showing
214
+ * that project refetch; the others ignore it. Broadcasting a full INITIAL_STATE
215
+ * here used to drag every client onto the writer's project.
216
+ */
217
+ function broadcastDocsChanged(project) {
218
+ broadcastToGUI({
219
+ type: "DOCS_CHANGED",
220
+ data: { project },
221
+ timestamp: new Date().toISOString(),
222
+ });
223
+ }
224
+ /**
225
+ * Server-side state only: the project list and server capabilities. Which
226
+ * project a client displays is that client's own business (URL + local
227
+ * preference), so no docs and no active project are pushed here.
228
+ */
191
229
  async function handleGetInitialState(msg) {
192
230
  try {
193
231
  const config = storageEngine.getConfig();
194
- const docs = await storageEngine.listDocs();
195
- for (const doc of docs) {
196
- const full = await storageEngine.getDoc(doc.id); // doc.id is now the full path
197
- if (full && full.tracked_files.length > 0) {
198
- const report = computeFreshness(full.tracked_files, config.currentProject);
199
- doc.isStale = report.status === "stale" || report.status === "broken";
200
- doc.isBroken = report.status === "broken";
201
- }
202
- }
203
232
  const bundles = storageEngine.getBundles();
204
233
  broadcastToGUI({
205
234
  type: "INITIAL_STATE",
206
235
  correlationId: msg.correlationId,
207
236
  data: {
208
237
  config,
209
- docs,
210
238
  projects: config.projects,
211
239
  bundles,
212
240
  readOnly: isReadOnly(),
@@ -228,39 +256,31 @@ async function handleGetConfig(msg) {
228
256
  timestamp: new Date().toISOString(),
229
257
  });
230
258
  }
231
- async function handleUpdateProject(msg) {
259
+ /**
260
+ * Creates an empty project. It does NOT change any server-side "active"
261
+ * project — there is none: the selector in the GUI is a per-client view.
262
+ */
263
+ async function handleCreateProject(msg) {
232
264
  try {
233
265
  const project = path.basename(msg.data.project);
234
266
  if (!project || project === "..")
235
267
  throw new Error("Invalid project name");
236
268
  storageEngine.ensureProject(project);
237
- const config = storageEngine.getConfig();
238
- config.currentProject = project;
239
- storageEngine.saveConfig(config);
240
- const docs = await storageEngine.listDocs();
241
- for (const doc of docs) {
242
- const full = await storageEngine.getDoc(doc.id);
243
- if (full && full.tracked_files.length > 0) {
244
- const report = computeFreshness(full.tracked_files, project);
245
- doc.isStale = report.status === "stale" || report.status === "broken";
246
- doc.isBroken = report.status === "broken";
247
- }
248
- }
249
269
  broadcastToGUI({
250
- type: "UPDATE_PROJECT_RESPONSE",
270
+ type: "CREATE_PROJECT_RESPONSE",
251
271
  correlationId: msg.correlationId,
252
- data: { success: true, currentProject: project },
272
+ data: { success: true, project, projects: storageEngine.getConfig().projects },
253
273
  timestamp: new Date().toISOString(),
254
274
  });
255
275
  broadcastToGUI({
256
- type: "INITIAL_STATE",
257
- data: { config: storageEngine.getConfig(), docs, projects: storageEngine.getConfig().projects },
276
+ type: "PROJECTS_CHANGED",
277
+ data: { projects: storageEngine.getConfig().projects },
258
278
  timestamp: new Date().toISOString(),
259
279
  });
260
280
  }
261
281
  catch (err) {
262
282
  broadcastToGUI({
263
- type: "UPDATE_PROJECT_RESPONSE",
283
+ type: "CREATE_PROJECT_RESPONSE",
264
284
  correlationId: msg.correlationId,
265
285
  error: err.message,
266
286
  timestamp: new Date().toISOString(),
@@ -278,16 +298,7 @@ async function handleListProjects(msg) {
278
298
  }
279
299
  async function handleListDocs(msg) {
280
300
  try {
281
- const currentProject = storageEngine.getConfig().currentProject;
282
- const docs = await storageEngine.listDocs();
283
- for (const doc of docs) {
284
- const full = await storageEngine.getDoc(doc.id);
285
- if (full && full.tracked_files.length > 0) {
286
- const report = computeFreshness(full.tracked_files, currentProject);
287
- doc.isStale = report.status === "stale" || report.status === "broken";
288
- doc.isBroken = report.status === "broken";
289
- }
290
- }
301
+ const docs = await listDocsWithFreshness(requireProjectFromIPC(msg));
291
302
  broadcastToGUI({
292
303
  type: "LIST_DOCS_RESPONSE",
293
304
  correlationId: msg.correlationId,
@@ -327,8 +338,7 @@ async function handleGetDoc(msg) {
327
338
  }
328
339
  async function handleListFoldersIPC(msg) {
329
340
  try {
330
- const project = msg.data?.project;
331
- const docs = await storageEngine.listDocs(project);
341
+ const docs = await storageEngine.listDocs(requireProjectFromIPC(msg));
332
342
  const folders = new Set();
333
343
  for (const doc of docs) {
334
344
  if (doc.folder) {
@@ -394,21 +404,7 @@ async function handleCreateDocIPC(msg) {
394
404
  data: { success: true, id },
395
405
  timestamp: new Date().toISOString(),
396
406
  });
397
- const config = storageEngine.getConfig();
398
- const docs = await storageEngine.listDocs();
399
- for (const doc of docs) {
400
- const full = await storageEngine.getDoc(doc.id);
401
- if (full && full.tracked_files.length > 0) {
402
- const report = computeFreshness(full.tracked_files, config.currentProject);
403
- doc.isStale = report.status === "stale" || report.status === "broken";
404
- doc.isBroken = report.status === "broken";
405
- }
406
- }
407
- broadcastToGUI({
408
- type: "INITIAL_STATE",
409
- data: { config, docs, projects: config.projects },
410
- timestamp: new Date().toISOString(),
411
- });
407
+ broadcastDocsChanged(project);
412
408
  }
413
409
  catch (err) {
414
410
  broadcastToGUI({
@@ -430,9 +426,10 @@ async function handleUpdateDocIPC(msg) {
430
426
  return;
431
427
  }
432
428
  try {
429
+ const project = requireProjectFromIPC(msg);
433
430
  const result = await updateDoc(storageEngine, embeddingEngine, {
434
431
  path: msg.data.id,
435
- project: requireProjectFromIPC(msg),
432
+ project,
436
433
  content: msg.data.content,
437
434
  ...(msg.data.title !== undefined && { title: msg.data.title }),
438
435
  });
@@ -453,6 +450,7 @@ async function handleUpdateDocIPC(msg) {
453
450
  data: { success: true, message },
454
451
  timestamp: new Date().toISOString(),
455
452
  });
453
+ broadcastDocsChanged(project);
456
454
  }
457
455
  catch (err) {
458
456
  broadcastToGUI({
@@ -493,21 +491,7 @@ async function handleDeleteDocIPC(msg) {
493
491
  data: { success: true, message },
494
492
  timestamp: new Date().toISOString(),
495
493
  });
496
- const config = storageEngine.getConfig();
497
- const docs = await storageEngine.listDocs();
498
- for (const doc of docs) {
499
- const full = await storageEngine.getDoc(doc.id);
500
- if (full && full.tracked_files.length > 0) {
501
- const report = computeFreshness(full.tracked_files, config.currentProject);
502
- doc.isStale = report.status === "stale" || report.status === "broken";
503
- doc.isBroken = report.status === "broken";
504
- }
505
- }
506
- broadcastToGUI({
507
- type: "INITIAL_STATE",
508
- data: { config, docs, projects: config.projects },
509
- timestamp: new Date().toISOString(),
510
- });
494
+ broadcastDocsChanged(project);
511
495
  }
512
496
  catch (err) {
513
497
  broadcastToGUI({
@@ -603,9 +587,9 @@ async function handleSearchDocs(msg) {
603
587
  });
604
588
  }
605
589
  else {
606
- const currentProject = storageEngine.getConfig().currentProject;
607
- results = await embeddingEngine.search(query, currentProject, top_k || 5, async (docPath) => {
608
- const doc = await storageEngine.getDoc(docPath);
590
+ const project = requireProjectFromIPC(msg);
591
+ results = await embeddingEngine.search(query, project, top_k || 5, async (docPath) => {
592
+ const doc = await storageEngine.getDoc(docPath, project);
609
593
  if (!doc)
610
594
  return null;
611
595
  return { title: doc.title, content: doc.content };
@@ -832,9 +816,13 @@ function startGuiWorker() {
832
816
  });
833
817
  return child;
834
818
  }
819
+ /** La GUI a besoin soit d'un proxy, soit d'un port local (mode standalone). */
820
+ function guiIsConfigured() {
821
+ return !!(process.env.PROXY_URL?.trim() || process.env.APP_PORT?.trim());
822
+ }
835
823
  function launchGUI() {
836
- if (!process.env.PROXY_URL) {
837
- logger.info('[MASTER] PROXY_URL non défini — GUI désactivée.');
824
+ if (!guiIsConfigured()) {
825
+ logger.info('[MASTER] Ni PROXY_URL ni APP_PORT défini — GUI désactivée.');
838
826
  guiStatus = "disabled";
839
827
  return;
840
828
  }
@@ -842,10 +830,10 @@ function launchGUI() {
842
830
  startGuiWorker();
843
831
  }
844
832
  async function reconnectGui(args) {
845
- getConfig(); // relit le .env au cas où PROXY_URL aurait été ajouté après le démarrage
846
- if (!process.env.PROXY_URL) {
833
+ getConfig(); // relit le .env au cas où PROXY_URL/APP_PORT aurait été ajouté après le démarrage
834
+ if (!guiIsConfigured()) {
847
835
  return {
848
- content: [{ type: "text", text: "Error: PROXY_URL is not set — the GUI is disabled. Add PROXY_URL to the .env file, then call reconnect_gui again." }],
836
+ content: [{ type: "text", text: "Error: neither PROXY_URL nor APP_PORT is set — the GUI is disabled. Add PROXY_URL (to expose the GUI through the proxy) or APP_PORT (to serve it directly on localhost) to the .env file, then call reconnect_gui again." }],
849
837
  isError: true,
850
838
  };
851
839
  }
@@ -854,9 +842,9 @@ async function reconnectGui(args) {
854
842
  const force = args?.force === true;
855
843
  if (alive && !force) {
856
844
  if (guiStatus === "starting") {
857
- return { content: [{ type: "text", text: "GUI worker is already starting (proxy registration in progress). Wait a few seconds, then call reconnect_gui again if needed." }] };
845
+ return { content: [{ type: "text", text: "GUI worker is already starting. Wait a few seconds, then call reconnect_gui again if needed." }] };
858
846
  }
859
- return { content: [{ type: "text", text: `GUI is already registered with the proxy${guiUrl ? ` at ${guiUrl}` : ""}. Nothing to do (use force=true to restart it anyway).` }] };
847
+ return { content: [{ type: "text", text: `GUI is already running${guiUrl ? ` at ${guiUrl}` : ""}. Nothing to do (use force=true to restart it anyway).` }] };
860
848
  }
861
849
  logger.info(`[MASTER] reconnect_gui: ${alive ? "restarting (force)" : "relaunching"} GUI worker...`);
862
850
  const result = await new Promise((resolve) => {
@@ -867,20 +855,24 @@ async function reconnectGui(args) {
867
855
  guiStartWaiter = (r) => { cleanup(); resolve(r); };
868
856
  newChild.on("exit", onExit);
869
857
  });
858
+ const proxyUrl = process.env.PROXY_URL?.trim();
870
859
  if (result.outcome === "ready") {
871
- return { content: [{ type: "text", text: `GUI reconnected and registered with the proxy. URL: ${result.url}` }] };
860
+ return { content: [{ type: "text", text: `GUI reconnected. URL: ${result.url}` }] };
872
861
  }
873
862
  if (result.outcome === "already_running") {
874
- return { content: [{ type: "text", text: `GUI is already registered by another MCP instance. URL: ${result.url ?? "unknown"}` }] };
863
+ return { content: [{ type: "text", text: `GUI is already served by another MCP instance. URL: ${result.url ?? "unknown"}` }] };
875
864
  }
876
865
  if (result.outcome === "exited") {
866
+ const hint = proxyUrl
867
+ ? `The proxy server is probably still unreachable at ${proxyUrl}. Start the proxy server, then call reconnect_gui again.`
868
+ : `Port ${process.env.APP_PORT} is probably already taken by another process. Free it or set another APP_PORT, then call reconnect_gui again.`;
877
869
  return {
878
- content: [{ type: "text", text: `GUI worker exited before completing registration (code=${result.code}). The proxy server is probably still unreachable at ${process.env.PROXY_URL}. Start the proxy server, then call reconnect_gui again.` }],
870
+ content: [{ type: "text", text: `GUI worker exited before it was ready (code=${result.code}). ${hint}` }],
879
871
  isError: true,
880
872
  };
881
873
  }
882
874
  return {
883
- content: [{ type: "text", text: `GUI worker started but did not confirm proxy registration within 15s. Check the logs (${resolveLogDir(rootDir)}), then retry.` }],
875
+ content: [{ type: "text", text: `GUI worker started but did not confirm it was ready within 15s. Check the logs (${resolveLogDir(rootDir)}), then retry.` }],
884
876
  isError: true,
885
877
  };
886
878
  }
@@ -1059,20 +1051,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1059
1051
  required: [],
1060
1052
  },
1061
1053
  },
1062
- {
1063
- name: "set_project",
1064
- description: "Switch the active project and update it as the default for all subsequent MCP tool operations. If the project name does not exist yet, it is created automatically. Also controls what the GUI displays.",
1065
- inputSchema: {
1066
- type: "object",
1067
- properties: {
1068
- project: { type: "string", description: "Project name to switch to (created if it does not exist)" },
1069
- },
1070
- required: ["project"],
1071
- },
1072
- },
1073
1054
  {
1074
1055
  name: "list_projects",
1075
- description: "List all available documentation projects and show which one is currently active.",
1056
+ description: "List all available documentation projects. There is no active project: every tool call names the project it targets.",
1076
1057
  inputSchema: {
1077
1058
  type: "object",
1078
1059
  properties: {},
@@ -1154,9 +1135,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1154
1135
  inputSchema: {
1155
1136
  type: "object",
1156
1137
  properties: {
1157
- project: { type: "string", description: "Project to check (defaults to current project)" },
1138
+ project: { type: "string", description: "Project whose entries are checked. Required — never defaulted to a current project." },
1158
1139
  },
1159
- required: [],
1140
+ required: ["project"],
1160
1141
  },
1161
1142
  },
1162
1143
  {
@@ -1362,7 +1343,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1362
1343
  type: "object",
1363
1344
  properties: {
1364
1345
  hunk: { type: "number", description: "Hunk number from get_conflict_hunks" },
1365
- take: { type: "string", enum: ["local", "remote", "both", "none", "custom"], description: "'local' this machine, 'remote' the other machine, 'both' concatenates local then remote, 'none' drops the passage, 'custom' uses the provided content" },
1346
+ take: { type: "string", enum: ["local", "remote", "both", "none", "custom"], description: "'local' this machine, 'remote' the other machine, 'both' concatenates local then remote, 'none' drops the passage, 'custom' uses the provided content. 'both' is refused on frontmatter hunks, where it would duplicate a YAML key." },
1366
1347
  content: { type: "string", description: "Replacement text, required when take is 'custom'" },
1367
1348
  },
1368
1349
  required: ["hunk", "take"],
@@ -1410,7 +1391,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1410
1391
  case "check_freshness":
1411
1392
  return await checkFreshness(storageEngine, args);
1412
1393
  case "set_project":
1413
- return await setProject(storageEngine, args);
1394
+ // Removed: there is no server-side active project any more. Kept as an
1395
+ // explicit answer for agents that still remember the old tool.
1396
+ return {
1397
+ isError: true,
1398
+ content: [
1399
+ {
1400
+ type: "text",
1401
+ text: "set_project no longer exists: there is no active project. " +
1402
+ "Pass the project explicitly to every tool call (use list_projects to see them). " +
1403
+ "A project is created on demand by create_doc.",
1404
+ },
1405
+ ],
1406
+ };
1414
1407
  case "list_projects":
1415
1408
  return await listProjects(storageEngine);
1416
1409
  case "refresh_file_snapshot":
@@ -1505,9 +1498,6 @@ async function syncPullOnStartup() {
1505
1498
  await reindexEmbeddings(storageEngine, embeddingEngine, embeddingStore, {});
1506
1499
  }
1507
1500
  }
1508
- else if (res.status === "diverged") {
1509
- logger.warn(`Startup sync: local history diverged (ahead ${res.ahead}, behind ${res.behind}). Started on local data — run sync_status / sync_docs to reconcile.`);
1510
- }
1511
1501
  else if (res.status === "conflict") {
1512
1502
  logger.warn(`Startup sync: merge conflict in ${res.conflicted.length} file(s). Started on local data — resolve with resolve_sync_conflict.`);
1513
1503
  }
@@ -38,7 +38,7 @@ export async function checkLinks(storageEngine, args) {
38
38
  try {
39
39
  const validated = CheckLinksArgsSchema.parse(args);
40
40
  const config = storageEngine.getConfig();
41
- const project = validated.project ?? config.currentProject;
41
+ const project = validated.project;
42
42
  const knownProjects = new Set(config.projects);
43
43
  const docs = await storageEngine.listDocs(project);
44
44
  const validIds = new Set(docs.map((d) => d.id));