laporan-generator 2.6.2 → 2.7.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/lib/mcp.js ADDED
@@ -0,0 +1,569 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const readline = require("readline");
6
+ const https = require("https");
7
+ const { spawnSync } = require("child_process");
8
+ const { initProject } = require("./init");
9
+
10
+ const VERSION = "2.7.0";
11
+ const PACKAGE_ROOT = path.join(__dirname, "..");
12
+
13
+ function getPythonCommand() {
14
+ if (process.platform === "win32") {
15
+ const testPy = spawnSync("python", ["--version"], { stdio: "ignore" });
16
+ if (testPy.status === 0) return "python";
17
+ const testPy3 = spawnSync("python3", ["--version"], { stdio: "ignore" });
18
+ if (testPy3.status === 0) return "python3";
19
+ return "python";
20
+ }
21
+ const testPy3 = spawnSync("python3", ["--version"], { stdio: "ignore" });
22
+ if (testPy3.status === 0) return "python3";
23
+ return "python";
24
+ }
25
+
26
+ function httpsGet(url, options = {}, maxRedirects = 5) {
27
+ return new Promise((resolve, reject) => {
28
+ if (maxRedirects < 0) return reject(new Error("Too many HTTP redirects"));
29
+ const u = new URL(url);
30
+ const reqOpts = {
31
+ hostname: u.hostname,
32
+ path: u.pathname + u.search,
33
+ headers: Object.assign({
34
+ "User-Agent": "LaporanGenerator/" + VERSION + " (academic-document-engine; mailto:support@example.com)"
35
+ }, options.headers || {})
36
+ };
37
+
38
+ https.get(reqOpts, (res) => {
39
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
40
+ return resolve(httpsGet(res.headers.location, options, maxRedirects - 1));
41
+ }
42
+ let body = "";
43
+ res.on("data", (chunk) => { body += chunk; });
44
+ res.on("end", () => {
45
+ resolve({ statusCode: res.statusCode, headers: res.headers, body });
46
+ });
47
+ }).on("error", reject);
48
+ });
49
+ }
50
+
51
+ const TOOLS = [
52
+ {
53
+ name: "laporan_init",
54
+ description: "Initialize a new academic report, thesis (skripsi/TA), internship report (PKL), or scientific paper workspace with university presets (UI, ITB, UGM, ITS, Unpad, standard, skripsi-4433).",
55
+ inputSchema: {
56
+ type: "object",
57
+ properties: {
58
+ target_dir: {
59
+ type: "string",
60
+ description: "Target directory where the project will be initialized. Defaults to current directory."
61
+ },
62
+ preset: {
63
+ type: "string",
64
+ enum: ["standard", "skripsi-4433", "itb-ta", "ui-skripsi", "ugm-skripsi", "its-skripsi", "unpad-skripsi"],
65
+ description: "Campus preset defining margins (e.g. 4-4-3-3), fonts, line-spacing, and heading rules."
66
+ },
67
+ title: {
68
+ type: "string",
69
+ description: "Document title (will be formatted as inverted pyramid on cover)."
70
+ },
71
+ author_name: {
72
+ type: "string",
73
+ description: "Full name of the author/student."
74
+ },
75
+ author_id: {
76
+ type: "string",
77
+ description: "Student ID or NIM."
78
+ },
79
+ institution: {
80
+ type: "string",
81
+ description: "University or institution name."
82
+ },
83
+ faculty: {
84
+ type: "string",
85
+ description: "Faculty name."
86
+ },
87
+ department: {
88
+ type: "string",
89
+ description: "Study program or department."
90
+ },
91
+ year: {
92
+ type: "string",
93
+ description: "Academic year (e.g. 2026)."
94
+ },
95
+ force: {
96
+ type: "boolean",
97
+ description: "Whether to overwrite existing files if already present."
98
+ }
99
+ }
100
+ }
101
+ },
102
+ {
103
+ name: "laporan_doctor",
104
+ description: "Audit document health and integrity: verify system dependencies (Pandoc, Typst, Python), missing citation keys in references.bib, broken local figure paths, manual heading violations, and math notation.",
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ target_dir: {
109
+ type: "string",
110
+ description: "Target directory containing the document. Defaults to current directory."
111
+ }
112
+ }
113
+ }
114
+ },
115
+ {
116
+ name: "laporan_stats",
117
+ description: "Compute comprehensive document metrics: word count, character count, estimated page count (A4 1.5 spacing), reading duration, chapter breakdown, headings, equations, and citation counts.",
118
+ inputSchema: {
119
+ type: "object",
120
+ properties: {
121
+ target_dir: {
122
+ type: "string",
123
+ description: "Target directory containing the document. Defaults to current directory."
124
+ }
125
+ }
126
+ }
127
+ },
128
+ {
129
+ name: "laporan_presets",
130
+ description: "List all supported university presets or get detailed layout specifications (margins in cm, fonts, line spacing, heading formats) for a specific university preset.",
131
+ inputSchema: {
132
+ type: "object",
133
+ properties: {
134
+ action: {
135
+ type: "string",
136
+ enum: ["list", "get"],
137
+ default: "list",
138
+ description: "Action to perform: list all presets, or get details of a specific preset."
139
+ },
140
+ preset_name: {
141
+ type: "string",
142
+ description: "Preset name (e.g. standard, skripsi-4433, ui-skripsi, itb-ta, ugm-skripsi, its-skripsi, unpad-skripsi) when action is get."
143
+ }
144
+ }
145
+ }
146
+ },
147
+ {
148
+ name: "laporan_citations",
149
+ description: "Search academic citations via Crossref API or validate and inspect citations in references.bib. Can fetch official BibTeX entries by DOI or title and append them to references.bib without manual scraping.",
150
+ inputSchema: {
151
+ type: "object",
152
+ properties: {
153
+ action: {
154
+ type: "string",
155
+ enum: ["search_doi", "get_bibtex", "add_citation", "validate"],
156
+ description: "Action: search_doi (query Crossref for papers), get_bibtex (fetch official BibTeX by DOI), add_citation (append BibTeX to references.bib), or validate (check integrity)."
157
+ },
158
+ query: {
159
+ type: "string",
160
+ description: "Search query (title, author, keyword) for searching Crossref."
161
+ },
162
+ doi: {
163
+ type: "string",
164
+ description: "DOI string (e.g. 10.1145/3377811.3380327 or https://doi.org/...) to fetch BibTeX."
165
+ },
166
+ bibtex: {
167
+ type: "string",
168
+ description: "Raw BibTeX entry to append when action is add_citation."
169
+ },
170
+ target_dir: {
171
+ type: "string",
172
+ description: "Target directory of the project. Defaults to current directory."
173
+ }
174
+ },
175
+ required: ["action"]
176
+ }
177
+ },
178
+ {
179
+ name: "laporan_build",
180
+ description: "Compile the academic document into publication-grade PDF (via Typst) and standard Microsoft Word DOCX with Roman/Arabic dual page numbering.",
181
+ inputSchema: {
182
+ type: "object",
183
+ properties: {
184
+ target_dir: {
185
+ type: "string",
186
+ description: "Document directory. Defaults to current directory."
187
+ },
188
+ format: {
189
+ type: "string",
190
+ enum: ["all", "pdf", "docx"],
191
+ default: "all",
192
+ description: "Target format to build: all (both PDF and DOCX), pdf, or docx."
193
+ }
194
+ }
195
+ }
196
+ }
197
+ ];
198
+
199
+ async function handleToolCall(name, args = {}) {
200
+ const targetDir = args.target_dir ? path.resolve(args.target_dir) : process.cwd();
201
+
202
+ switch (name) {
203
+ case "laporan_init": {
204
+ const res = initProject({
205
+ targetDir,
206
+ preset: args.preset,
207
+ title: args.title,
208
+ authorName: args.author_name,
209
+ authorId: args.author_id,
210
+ institution: args.institution,
211
+ faculty: args.faculty,
212
+ department: args.department,
213
+ year: args.year,
214
+ force: args.force,
215
+ silent: true
216
+ });
217
+ return {
218
+ content: [
219
+ {
220
+ type: "text",
221
+ text: JSON.stringify(res, null, 2)
222
+ }
223
+ ]
224
+ };
225
+ }
226
+
227
+ case "laporan_doctor": {
228
+ const script = path.join(PACKAGE_ROOT, "scripts/report-doctor.py");
229
+ const py = getPythonCommand();
230
+ const proc = spawnSync(py, [script, targetDir, "--json"], { encoding: "utf8" });
231
+ let output = proc.stdout ? proc.stdout.trim() : "";
232
+ try {
233
+ const parsed = JSON.parse(output);
234
+ return {
235
+ content: [{ type: "text", text: JSON.stringify(parsed, null, 2) }]
236
+ };
237
+ } catch (_) {
238
+ return {
239
+ content: [{ type: "text", text: output || proc.stderr || "Doctor check executed" }]
240
+ };
241
+ }
242
+ }
243
+
244
+ case "laporan_stats": {
245
+ const script = path.join(PACKAGE_ROOT, "scripts/report-stats.py");
246
+ const py = getPythonCommand();
247
+ const proc = spawnSync(py, [script, targetDir, "--json"], { encoding: "utf8" });
248
+ let output = proc.stdout ? proc.stdout.trim() : "";
249
+ try {
250
+ const parsed = JSON.parse(output);
251
+ return {
252
+ content: [{ type: "text", text: JSON.stringify(parsed, null, 2) }]
253
+ };
254
+ } catch (_) {
255
+ return {
256
+ content: [{ type: "text", text: output || proc.stderr || "Stats check executed" }]
257
+ };
258
+ }
259
+ }
260
+
261
+ case "laporan_presets": {
262
+ const presetsDir = path.join(PACKAGE_ROOT, "presets");
263
+ const action = args.action || "list";
264
+
265
+ if (action === "get") {
266
+ const presetName = (args.preset_name || "").replace(/\.ya?ml$/, "");
267
+ if (!presetName) {
268
+ throw new Error("Missing preset_name argument for action get");
269
+ }
270
+ const filePath = path.join(presetsDir, presetName + ".yml");
271
+ if (!fs.existsSync(filePath)) {
272
+ throw new Error("Preset + presetName + not found in " + presetsDir);
273
+ }
274
+ const content = fs.readFileSync(filePath, "utf8");
275
+ return {
276
+ content: [{ type: "text", text: content }]
277
+ };
278
+ }
279
+
280
+ // Action: list
281
+ const files = fs.readdirSync(presetsDir).filter((f) => f.endsWith(".yml"));
282
+ const presets = files.map((f) => {
283
+ const full = path.join(presetsDir, f);
284
+ const raw = fs.readFileSync(full, "utf8");
285
+ const name = f.replace(/\.ya?ml$/, "");
286
+ const descMatch = raw.match(/^description:\s*["']?(.*?)["']?$/m);
287
+ return {
288
+ preset: name,
289
+ filename: f,
290
+ description: descMatch ? descMatch[1] : name
291
+ };
292
+ });
293
+
294
+ return {
295
+ content: [{ type: "text", text: JSON.stringify({ presets }, null, 2) }]
296
+ };
297
+ }
298
+
299
+ case "laporan_citations": {
300
+ const action = args.action;
301
+
302
+ if (action === "search_doi") {
303
+ if (!args.query) throw new Error("Missing query for action search_doi");
304
+ const queryUrl = "https://api.crossref.org/works?query=" + encodeURIComponent(args.query) + "&rows=5";
305
+ const resp = await httpsGet(queryUrl);
306
+ if (resp.statusCode !== 200) {
307
+ throw new Error("Crossref API returned status " + resp.statusCode);
308
+ }
309
+ const data = JSON.parse(resp.body);
310
+ const items = (data.message && data.message.items ? data.message.items : []).map((it) => {
311
+ const authors = (it.author || []).map((a) => (a.given ? a.given + " " : "") + (a.family || "")).join(", ");
312
+ const year = it.issued && it.issued["date-parts"] && it.issued["date-parts"][0] ? it.issued["date-parts"][0][0] : null;
313
+ return {
314
+ doi: it.DOI,
315
+ title: it.title ? it.title[0] : "Untitled",
316
+ authors: authors || "Unknown",
317
+ year: year,
318
+ container: it["container-title"] ? it["container-title"][0] : null,
319
+ type: it.type
320
+ };
321
+ });
322
+ return {
323
+ content: [{ type: "text", text: JSON.stringify({ query: args.query, count: items.length, items }, null, 2) }]
324
+ };
325
+ }
326
+
327
+ if (action === "get_bibtex") {
328
+ let cleanDoi = (args.doi || "").trim();
329
+ cleanDoi = cleanDoi.replace(/^https?:\/\/(dx\.)?doi\.org\//i, "");
330
+ if (!cleanDoi) throw new Error("Missing doi parameter for action get_bibtex");
331
+ const doiUrl = "https://doi.org/" + encodeURIComponent(cleanDoi);
332
+ const resp = await httpsGet(doiUrl, {
333
+ headers: {
334
+ "Accept": "application/x-bibtex"
335
+ }
336
+ });
337
+ if (resp.statusCode !== 200) {
338
+ throw new Error("Failed to fetch BibTeX for DOI " + cleanDoi + ": HTTP " + resp.statusCode);
339
+ }
340
+ return {
341
+ content: [{ type: "text", text: resp.body.trim() }]
342
+ };
343
+ }
344
+
345
+ if (action === "add_citation") {
346
+ let bibtex = (args.bibtex || "").trim();
347
+ if (!bibtex && args.doi) {
348
+ // Auto fetch if only DOI was provided
349
+ let cleanDoi = args.doi.trim().replace(/^https?:\/\/(dx\.)?doi\.org\//i, "");
350
+ const resp = await httpsGet("https://doi.org/" + encodeURIComponent(cleanDoi), {
351
+ headers: { "Accept": "application/x-bibtex" }
352
+ });
353
+ if (resp.statusCode === 200) {
354
+ bibtex = resp.body.trim();
355
+ }
356
+ }
357
+ if (!bibtex) throw new Error("Missing bibtex entry or valid doi to add");
358
+ const bibFile = path.join(targetDir, "references.bib");
359
+ if (!fs.existsSync(bibFile)) {
360
+ fs.writeFileSync(bibFile, bibtex + "\n", "utf8");
361
+ } else {
362
+ fs.appendFileSync(bibFile, "\n\n" + bibtex + "\n", "utf8");
363
+ }
364
+
365
+ // Extract cite key
366
+ const keyMatch = bibtex.match(/@\w+\s*\{\s*([a-zA-Z0-9_:-]+)/);
367
+ const citeKey = keyMatch ? keyMatch[1] : null;
368
+
369
+ return {
370
+ content: [
371
+ {
372
+ type: "text",
373
+ text: JSON.stringify({
374
+ status: "SUCCESS",
375
+ message: "Citation appended to references.bib",
376
+ citeKey: citeKey,
377
+ markdownCitation: citeKey ? "[@" + citeKey + "]" : null
378
+ }, null, 2)
379
+ }
380
+ ]
381
+ };
382
+ }
383
+
384
+ if (action === "validate") {
385
+ const bibFile = path.join(targetDir, "references.bib");
386
+ if (!fs.existsSync(bibFile)) {
387
+ return {
388
+ content: [{ type: "text", text: JSON.stringify({ valid: false, error: "references.bib not found in " + targetDir }) }]
389
+ };
390
+ }
391
+ const bibContent = fs.readFileSync(bibFile, "utf8");
392
+ const keys = Array.from(bibContent.matchAll(/@\w+\s*\{\s*([a-zA-Z0-9_:-]+)/g)).map((m) => m[1]);
393
+ return {
394
+ content: [
395
+ {
396
+ type: "text",
397
+ text: JSON.stringify({
398
+ valid: true,
399
+ totalEntries: keys.length,
400
+ keys: keys
401
+ }, null, 2)
402
+ }
403
+ ]
404
+ };
405
+ }
406
+
407
+ throw new Error("Unknown action: " + action);
408
+ }
409
+
410
+ case "laporan_build": {
411
+ const format = args.format || "all";
412
+ const isWin = process.platform === "win32";
413
+ let proc;
414
+
415
+ if (isWin) {
416
+ const ps = path.join(PACKAGE_ROOT, "laporan.ps1");
417
+ proc = spawnSync("powershell", ["-ExecutionPolicy", "Bypass", "-File", ps, "build"], {
418
+ cwd: targetDir,
419
+ encoding: "utf8"
420
+ });
421
+ } else {
422
+ const script = path.join(PACKAGE_ROOT, "build.sh");
423
+ if (format === "docx") {
424
+ proc = spawnSync("make", ["docx"], { cwd: targetDir, encoding: "utf8" });
425
+ } else if (format === "pdf") {
426
+ proc = spawnSync("bash", [script], { cwd: targetDir, encoding: "utf8" });
427
+ } else {
428
+ // both
429
+ const procPdf = spawnSync("bash", [script], { cwd: targetDir, encoding: "utf8" });
430
+ const procDocx = spawnSync("make", ["docx"], { cwd: targetDir, encoding: "utf8" });
431
+ proc = {
432
+ status: (procPdf.status || 0) + (procDocx.status || 0),
433
+ stdout: (procPdf.stdout || "") + "\n" + (procDocx.stdout || ""),
434
+ stderr: (procPdf.stderr || "") + "\n" + (procDocx.stderr || "")
435
+ };
436
+ }
437
+ }
438
+
439
+ const pdfPath = path.join(targetDir, "Laporan.pdf");
440
+ const docxPath = path.join(targetDir, "Laporan.docx");
441
+
442
+ const res = {
443
+ status: proc.status === 0 ? "SUCCESS" : "FAILED",
444
+ exitCode: proc.status,
445
+ pdfGenerated: fs.existsSync(pdfPath),
446
+ pdfSize: fs.existsSync(pdfPath) ? fs.statSync(pdfPath).size : null,
447
+ docxGenerated: fs.existsSync(docxPath),
448
+ docxSize: fs.existsSync(docxPath) ? fs.statSync(docxPath).size : null,
449
+ outputLogs: proc.stdout ? proc.stdout.slice(-1000) : "",
450
+ errorLogs: proc.stderr ? proc.stderr.slice(-1000) : ""
451
+ };
452
+
453
+ return {
454
+ content: [{ type: "text", text: JSON.stringify(res, null, 2) }]
455
+ };
456
+ }
457
+
458
+ default:
459
+ throw new Error("Unknown tool: " + name);
460
+ }
461
+ }
462
+
463
+ function start() {
464
+ const rl = readline.createInterface({
465
+ input: process.stdin,
466
+ output: process.stdout,
467
+ terminal: false
468
+ });
469
+
470
+ function sendResponse(id, result) {
471
+ const payload = {
472
+ jsonrpc: "2.0",
473
+ id: id,
474
+ result: result
475
+ };
476
+ process.stdout.write(JSON.stringify(payload) + "\n");
477
+ }
478
+
479
+ function sendError(id, code, message, data) {
480
+ const payload = {
481
+ jsonrpc: "2.0",
482
+ id: id,
483
+ error: {
484
+ code: code,
485
+ message: message,
486
+ data: data
487
+ }
488
+ };
489
+ process.stdout.write(JSON.stringify(payload) + "\n");
490
+ }
491
+
492
+ rl.on("line", async (line) => {
493
+ line = line.trim();
494
+ if (!line) return;
495
+
496
+ let req;
497
+ try {
498
+ req = JSON.parse(line);
499
+ } catch (e) {
500
+ sendError(null, -32700, "Parse error: " + e.message);
501
+ return;
502
+ }
503
+
504
+ const { id, method, params } = req;
505
+
506
+ if (!id && method && method.startsWith("notifications/")) {
507
+ return;
508
+ }
509
+
510
+ try {
511
+ switch (method) {
512
+ case "initialize":
513
+ sendResponse(id, {
514
+ protocolVersion: "2024-11-05",
515
+ capabilities: {
516
+ tools: {}
517
+ },
518
+ serverInfo: {
519
+ name: "laporan-generator",
520
+ version: VERSION
521
+ }
522
+ });
523
+ break;
524
+
525
+ case "ping":
526
+ sendResponse(id, {});
527
+ break;
528
+
529
+ case "tools/list":
530
+ sendResponse(id, {
531
+ tools: TOOLS
532
+ });
533
+ break;
534
+
535
+ case "tools/call": {
536
+ if (!params || !params.name) {
537
+ sendError(id, -32602, "Invalid params: missing tool name");
538
+ return;
539
+ }
540
+ try {
541
+ const toolResult = await handleToolCall(params.name, params.arguments || {});
542
+ sendResponse(id, toolResult);
543
+ } catch (err) {
544
+ sendResponse(id, {
545
+ isError: true,
546
+ content: [
547
+ {
548
+ type: "text",
549
+ text: "Tool execution failed: " + err.message
550
+ }
551
+ ]
552
+ });
553
+ }
554
+ break;
555
+ }
556
+
557
+ default:
558
+ sendError(id, -32601, "Method not found: " + method);
559
+ break;
560
+ }
561
+ } catch (err) {
562
+ sendError(id, -32603, "Internal error: " + err.message);
563
+ }
564
+ });
565
+
566
+ process.stderr.write("Laporan Generator MCP Server v" + VERSION + " running on stdio\n");
567
+ }
568
+
569
+ module.exports = { start, handleToolCall, TOOLS, VERSION };