@kolmopdf/mcp-server 1.0.3 → 1.1.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/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
 
7
7
  // src/client.ts
8
+ import { randomUUID } from "crypto";
8
9
  import { Readable } from "stream";
9
10
  import { pipeline } from "stream/promises";
10
11
 
@@ -204,6 +205,12 @@ function errorFromApiBody(body, httpStatus) {
204
205
  }
205
206
 
206
207
  // src/client.ts
208
+ function normalizeStatus(status) {
209
+ if (!status) return "processing";
210
+ if (status === "completed") return "succeeded";
211
+ if (status === "pending" || status === "waiting") return "queued";
212
+ return status;
213
+ }
207
214
  var KolmoPdfClient = class {
208
215
  apiKey;
209
216
  baseUrl;
@@ -215,18 +222,38 @@ var KolmoPdfClient = class {
215
222
  this.httpTimeoutMs = opts.httpTimeoutMs;
216
223
  this.uploadTimeoutMs = opts.uploadTimeoutMs;
217
224
  }
218
- get apiBase() {
219
- return `${this.baseUrl}/api/pdf-to-markdown-proxy`;
225
+ get jobsBase() {
226
+ return `${this.baseUrl}/api/v1/jobs`;
220
227
  }
221
228
  headers() {
222
- return { "X-API-Key": this.apiKey };
229
+ return {
230
+ "X-API-Key": this.apiKey,
231
+ Authorization: `Bearer ${this.apiKey}`
232
+ };
223
233
  }
224
234
  async jsonRequest(url, init) {
225
235
  const res = await fetch(url, init);
226
- const body = await res.json();
236
+ let body = {};
237
+ const text = await res.text();
238
+ try {
239
+ body = text ? JSON.parse(text) : {};
240
+ } catch {
241
+ if (!res.ok) {
242
+ throw new KolmoPdfError("api_task_error", {
243
+ message: `HTTP ${res.status}: non-JSON body`,
244
+ httpStatus: res.status
245
+ });
246
+ }
247
+ }
227
248
  if (!res.ok || body.success === false) {
249
+ const errObj = body.error;
228
250
  throw errorFromApiBody(
229
- body,
251
+ {
252
+ error_code: body.error_code || errObj?.code,
253
+ message: body.message || errObj?.message,
254
+ points_required: body.points_required,
255
+ current_points: body.current_points
256
+ },
230
257
  res.status
231
258
  );
232
259
  }
@@ -247,6 +274,20 @@ var KolmoPdfClient = class {
247
274
  form.append("file", blob, filename);
248
275
  return form;
249
276
  }
277
+ normalizeSubmit(body) {
278
+ const id = String(body.id ?? body.task_id ?? body.legacy_task_id ?? "");
279
+ if (!id) {
280
+ throw new KolmoPdfError("task_creation_failed", { message: "No job id in create response" });
281
+ }
282
+ const queue = body.queue;
283
+ return {
284
+ task_id: id,
285
+ status: normalizeStatus(String(body.status ?? "queued")),
286
+ points_deducted: Number(body.points_deducted ?? 0),
287
+ remaining_points: Number(body.remaining_points ?? 0),
288
+ queue_info: queue && typeof queue.ahead === "number" ? { position: queue.position ?? 0, ahead_tasks: queue.ahead } : void 0
289
+ };
290
+ }
250
291
  async parse(file, form, filename) {
251
292
  const fd = await this.buildFileForm(file, filename);
252
293
  if (form.table_mode) fd.append("table_mode", form.table_mode);
@@ -260,13 +301,14 @@ var KolmoPdfClient = class {
260
301
  fd.append("skip_rotation_detection", String(form.skip_rotation_detection));
261
302
  if (form.enable_cross_page_merge !== void 0)
262
303
  fd.append("enable_cross_page_merge", String(form.enable_cross_page_merge));
263
- const body = await this.jsonRequest(`${this.apiBase}/parse`, {
304
+ if (form.enrichment !== void 0) fd.append("enrichment", form.enrichment);
305
+ const body = await this.jsonRequest(`${this.jobsBase}/parse`, {
264
306
  method: "POST",
265
- headers: this.headers(),
307
+ headers: { ...this.headers(), "Idempotency-Key": randomUUID() },
266
308
  body: fd,
267
309
  signal: AbortSignal.timeout(this.uploadTimeoutMs)
268
310
  });
269
- return body;
311
+ return this.normalizeSubmit(body);
270
312
  }
271
313
  async translatePdf(file, form, filename) {
272
314
  const fd = await this.buildFileForm(file, filename);
@@ -277,35 +319,77 @@ var KolmoPdfClient = class {
277
319
  fd.append("enableImageTranslation", String(form.enable_image_translation));
278
320
  if (form.enable_table_translation !== void 0)
279
321
  fd.append("enableTableTranslation", String(form.enable_table_translation));
280
- const body = await this.jsonRequest(`${this.apiBase}/translate-pdf`, {
322
+ const body = await this.jsonRequest(`${this.jobsBase}/translate-pdf`, {
281
323
  method: "POST",
282
- headers: this.headers(),
324
+ headers: { ...this.headers(), "Idempotency-Key": randomUUID() },
283
325
  body: fd,
284
326
  signal: AbortSignal.timeout(this.uploadTimeoutMs)
285
327
  });
286
- return body;
328
+ return this.normalizeSubmit(body);
287
329
  }
288
330
  async convert(file, form, filename) {
289
331
  const fd = await this.buildFileForm(file, filename);
290
332
  if (form.target_format) fd.append("targetFormat", form.target_format);
291
- const body = await this.jsonRequest(`${this.apiBase}/convert`, {
333
+ const body = await this.jsonRequest(`${this.jobsBase}/convert`, {
292
334
  method: "POST",
293
- headers: this.headers(),
335
+ headers: { ...this.headers(), "Idempotency-Key": randomUUID() },
294
336
  body: fd,
295
337
  signal: AbortSignal.timeout(this.uploadTimeoutMs)
296
338
  });
297
- return body;
339
+ return this.normalizeSubmit(body);
298
340
  }
299
341
  async getStatus(taskId) {
300
- const body = await this.jsonRequest(`${this.apiBase}/status/${taskId}`, {
342
+ const body = await this.jsonRequest(`${this.jobsBase}/${encodeURIComponent(taskId)}`, {
301
343
  method: "GET",
302
344
  headers: this.headers(),
303
345
  signal: AbortSignal.timeout(this.httpTimeoutMs)
304
346
  });
305
- return body;
347
+ const status = normalizeStatus(String(body.status ?? "processing"));
348
+ const err = body.error;
349
+ const queue = body.queue;
350
+ const result = body.result;
351
+ const ok = status === "succeeded" || status === "completed";
352
+ return {
353
+ success: ok,
354
+ status,
355
+ message: body.message || err?.message,
356
+ error_code: err?.code,
357
+ queue_info: queue && typeof queue.ahead === "number" ? { position: queue.position ?? 0, ahead_tasks: queue.ahead } : void 0,
358
+ result: result ? {
359
+ task_id: taskId,
360
+ download_url: result.download_url,
361
+ filename: result.filename ?? null,
362
+ kind: result.kind ?? null,
363
+ content_type: result.content_type ?? null,
364
+ sha256: result.sha256 ?? null,
365
+ bytes: result.bytes ?? null,
366
+ files: result.files ?? null
367
+ } : void 0
368
+ };
369
+ }
370
+ /** SSE stream for a job. Caller must abort/cancel the response body. */
371
+ async openEvents(taskId, signal) {
372
+ const res = await fetch(`${this.jobsBase}/${encodeURIComponent(taskId)}/events`, {
373
+ method: "GET",
374
+ headers: {
375
+ ...this.headers(),
376
+ Accept: "text/event-stream"
377
+ },
378
+ ...signal === void 0 ? {} : { signal }
379
+ });
380
+ if (!res.ok) {
381
+ throw new KolmoPdfError("api_task_error", {
382
+ message: `SSE failed with HTTP ${res.status}`,
383
+ httpStatus: res.status
384
+ });
385
+ }
386
+ return res;
306
387
  }
307
- async download(taskId, dest) {
308
- const res = await fetch(`${this.apiBase}/download/${taskId}`, {
388
+ /**
389
+ * Stream download to a Writable, or to destPath (preferred — allows ZIP sniff after write).
390
+ */
391
+ async download(taskId, dest, opts) {
392
+ const res = await fetch(`${this.jobsBase}/${encodeURIComponent(taskId)}/download`, {
309
393
  method: "GET",
310
394
  headers: this.headers(),
311
395
  signal: AbortSignal.timeout(this.uploadTimeoutMs)
@@ -317,32 +401,51 @@ var KolmoPdfClient = class {
317
401
  });
318
402
  }
319
403
  const contentType = res.headers.get("content-type");
320
- const isZip = contentType?.includes("zip") || contentType?.includes("octet-stream") || false;
404
+ let isZip = !!contentType && (contentType.includes("zip") || contentType.includes("application/octet-stream") || contentType.includes("application/x-zip"));
321
405
  const body = res.body;
322
406
  if (!body) {
323
407
  throw new KolmoPdfError("api_task_error", { message: "Empty download response body" });
324
408
  }
325
409
  const reader = body.getReader();
326
410
  let bytesWritten = 0;
411
+ const firstChunks = [];
412
+ let sniffed = false;
327
413
  async function* generate() {
328
414
  while (true) {
329
415
  const { done, value } = await reader.read();
330
416
  if (done) break;
331
- bytesWritten += value.byteLength;
332
- yield Buffer.from(value);
417
+ const buf = Buffer.from(value);
418
+ bytesWritten += buf.byteLength;
419
+ if (!sniffed) {
420
+ firstChunks.push(buf);
421
+ const head = Buffer.concat(firstChunks);
422
+ if (head.byteLength >= 4) {
423
+ if (head[0] === 80 && head[1] === 75 && (head[2] === 3 || head[2] === 5 || head[2] === 7)) {
424
+ isZip = true;
425
+ } else if (!contentType?.includes("zip")) {
426
+ isZip = false;
427
+ }
428
+ sniffed = true;
429
+ }
430
+ }
431
+ yield buf;
333
432
  }
334
433
  }
335
434
  const readable = Readable.from(generate());
336
435
  await pipeline(readable, dest);
337
- return { contentType, isZip, bytesWritten };
436
+ return { contentType, isZip, bytesWritten, destPath: opts?.destPath };
338
437
  }
339
438
  async getBalance() {
340
- const body = await this.jsonRequest(`${this.apiBase}/balance`, {
439
+ const body = await this.jsonRequest(`${this.baseUrl}/api/v1/balance`, {
341
440
  method: "GET",
342
441
  headers: this.headers(),
343
442
  signal: AbortSignal.timeout(this.httpTimeoutMs)
344
443
  });
345
- return body;
444
+ return {
445
+ success: body.success !== false,
446
+ points: Number(body.points ?? 0),
447
+ api_key: String(body.api_key ?? "")
448
+ };
346
449
  }
347
450
  };
348
451
 
@@ -405,8 +508,8 @@ async function checkBalanceHandler(_args, ctx) {
405
508
 
406
509
  // src/tools/convert.ts
407
510
  import { createWriteStream, mkdirSync } from "fs";
408
- import { readFile as readFile2 } from "fs/promises";
409
- import { basename, join, resolve } from "path";
511
+ import { readFile as readFile2, rename as rename2 } from "fs/promises";
512
+ import { basename, join as join2, resolve } from "path";
410
513
  import { z as z2 } from "zod";
411
514
 
412
515
  // src/pages.ts
@@ -433,8 +536,8 @@ function humanizeStatus(status, aheadTasks) {
433
536
  }
434
537
 
435
538
  // src/polling.ts
436
- var TERMINAL_OK = "completed";
437
- var TERMINAL_FAIL = "failed";
539
+ var TERMINAL_OK = /* @__PURE__ */ new Set(["succeeded", "completed"]);
540
+ var TERMINAL_FAIL = /* @__PURE__ */ new Set(["failed", "cancelled"]);
438
541
  var RETRY_POLICY = {
439
542
  maxAttempts: 3,
440
543
  baseDelayMs: 1e3,
@@ -464,19 +567,119 @@ async function fetchStatusWithRetry(client, taskId) {
464
567
  }
465
568
  throw new KolmoPdfError("client_network_error");
466
569
  }
570
+ function nextSseFrame(buf) {
571
+ const lf = buf.indexOf("\n\n");
572
+ const crlf = buf.indexOf("\r\n\r\n");
573
+ if (lf < 0 && crlf < 0) return null;
574
+ if (crlf >= 0 && (lf < 0 || crlf < lf)) {
575
+ return { frame: buf.slice(0, crlf), rest: buf.slice(crlf + 4) };
576
+ }
577
+ return { frame: buf.slice(0, lf), rest: buf.slice(lf + 2) };
578
+ }
579
+ function eventNameFromFrame(raw) {
580
+ let eventName = "message";
581
+ for (const line of raw.split(/\r?\n/)) {
582
+ if (line.startsWith("event:")) eventName = line.slice(6).trim();
583
+ }
584
+ return eventName;
585
+ }
586
+ async function waitViaSse(ctx, deadline) {
587
+ const { client, taskId, progress, options } = ctx;
588
+ const remaining = Math.max(1e3, deadline - Date.now());
589
+ const timeout = AbortSignal.timeout(remaining);
590
+ const parent = options.signal;
591
+ const combined = parent === void 0 ? timeout : AbortSignal.any([parent, timeout]);
592
+ let reader;
593
+ try {
594
+ const res = await client.openEvents(taskId, combined);
595
+ const body = res.body;
596
+ if (!body) return null;
597
+ reader = body.getReader();
598
+ const decoder = new TextDecoder();
599
+ let buf = "";
600
+ const handleEvent = async (eventName) => {
601
+ if (eventName === "job.succeeded") {
602
+ const status = await fetchStatusWithRetry(client, taskId);
603
+ if (TERMINAL_OK.has(String(status.status || ""))) {
604
+ await progress?.report(`[completed] Task ${taskId} done`);
605
+ return status;
606
+ }
607
+ return "continue";
608
+ }
609
+ if (eventName === "job.failed" || eventName === "job.cancelled") {
610
+ const failed = await fetchStatusWithRetry(client, taskId);
611
+ throw new KolmoPdfError(failed.error_code || eventName.slice("job.".length), {
612
+ message: failed.message || "Task failed"
613
+ });
614
+ }
615
+ if (eventName === "job.progress" || eventName === "job.snapshot") {
616
+ await progress?.report(humanizeStatus("processing"));
617
+ }
618
+ return "continue";
619
+ };
620
+ while (!timeout.aborted) {
621
+ if (parent?.aborted === true) throw new KolmoPdfError("client_polling_timeout");
622
+ const { done, value } = await reader.read();
623
+ if (done) {
624
+ buf += decoder.decode();
625
+ const last = nextSseFrame(`${buf}
626
+
627
+ `);
628
+ if (last) {
629
+ const result = await handleEvent(eventNameFromFrame(last.frame));
630
+ if (result !== "continue") return result;
631
+ }
632
+ break;
633
+ }
634
+ buf += decoder.decode(value, { stream: true });
635
+ let next = nextSseFrame(buf);
636
+ while (next) {
637
+ buf = next.rest;
638
+ const result = await handleEvent(eventNameFromFrame(next.frame));
639
+ if (result !== "continue") return result;
640
+ next = nextSseFrame(buf);
641
+ }
642
+ }
643
+ return null;
644
+ } catch (err) {
645
+ if (err instanceof KolmoPdfError) {
646
+ const code = err.errorCode;
647
+ if (code !== "api_task_error" && code !== "client_network_error" && code !== "client_polling_timeout") {
648
+ throw err;
649
+ }
650
+ }
651
+ return null;
652
+ } finally {
653
+ try {
654
+ await reader?.cancel();
655
+ } catch {
656
+ }
657
+ }
658
+ }
467
659
  async function pollUntilComplete(ctx) {
468
660
  const { client, taskId, options, progress } = ctx;
469
661
  const deadline = Date.now() + options.maxPollMinutes * 6e4;
662
+ const viaSse = await waitViaSse(ctx, deadline);
663
+ if (viaSse && TERMINAL_OK.has(String(viaSse.status || ""))) return viaSse;
664
+ if (viaSse && TERMINAL_FAIL.has(String(viaSse.status || ""))) {
665
+ throw new KolmoPdfError(viaSse.error_code || "api_task_error", {
666
+ message: viaSse.message || "Task failed"
667
+ });
668
+ }
470
669
  while (true) {
670
+ if (options.signal?.aborted === true) {
671
+ throw new KolmoPdfError("client_polling_timeout");
672
+ }
471
673
  if (Date.now() > deadline) {
472
674
  throw new KolmoPdfError("client_polling_timeout");
473
675
  }
474
676
  const result = await fetchStatusWithRetry(client, taskId);
475
- if (result.status === TERMINAL_OK) {
677
+ const status = String(result.status || "");
678
+ if (TERMINAL_OK.has(status)) {
476
679
  await progress?.report(`[completed] Task ${taskId} done`);
477
680
  return result;
478
681
  }
479
- if (result.status === TERMINAL_FAIL) {
682
+ if (TERMINAL_FAIL.has(status)) {
480
683
  throw new KolmoPdfError(result.error_code || "api_task_error", {
481
684
  message: result.message || "Task failed"
482
685
  });
@@ -487,6 +690,52 @@ async function pollUntilComplete(ctx) {
487
690
  }
488
691
  }
489
692
 
693
+ // src/sniff.ts
694
+ import { open, rename } from "fs/promises";
695
+ import { join } from "path";
696
+ var EXT = {
697
+ zip: ".zip",
698
+ pdf: ".pdf",
699
+ markdown: ".md",
700
+ docx: ".docx",
701
+ html: ".html",
702
+ latex: ".tex",
703
+ binary: ".bin"
704
+ };
705
+ function extensionForKind(kind) {
706
+ return EXT[kind];
707
+ }
708
+ function sniffBytes(buf) {
709
+ if (buf.length >= 4 && buf[0] === 80 && buf[1] === 75 && (buf[2] === 3 || buf[2] === 5 || buf[2] === 7)) {
710
+ const hay = Buffer.from(buf.subarray(0, Math.min(buf.length, 65536))).toString("latin1");
711
+ if (hay.includes("word/document.xml") || hay.includes("wordprocessingml.document") || hay.includes("[Content_Types].xml") && hay.toLowerCase().includes("word/")) {
712
+ return "docx";
713
+ }
714
+ return "zip";
715
+ }
716
+ if (buf.length >= 4 && buf[0] === 37 && buf[1] === 80 && buf[2] === 68 && buf[3] === 70) {
717
+ return "pdf";
718
+ }
719
+ const head = Buffer.from(buf.subarray(0, Math.min(buf.length, 800))).toString("utf8");
720
+ const trimmed = head.trimStart().toLowerCase();
721
+ if (trimmed.startsWith("<!doctype html") || trimmed.startsWith("<html")) return "html";
722
+ if (trimmed.startsWith("\\documentclass") || trimmed.startsWith("\\begin{document}"))
723
+ return "latex";
724
+ if (head.trimStart().startsWith("#") || head.includes("\n# ") || head.includes("\n```"))
725
+ return "markdown";
726
+ return "binary";
727
+ }
728
+ async function sniffFile(filePath) {
729
+ const handle = await open(filePath, "r");
730
+ try {
731
+ const bytes = Buffer.alloc(65536);
732
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
733
+ return sniffBytes(bytes.subarray(0, bytesRead));
734
+ } finally {
735
+ await handle.close();
736
+ }
737
+ }
738
+
490
739
  // src/tools/convert.ts
491
740
  var convertName = "kolmopdf_convert_markdown";
492
741
  var convertDescription = "Convert a Markdown file (or a ZIP of markdown + images) to DOCX, HTML, PDF, or LaTeX via KolmoPDF.";
@@ -495,22 +744,6 @@ var convertInputSchema = z2.object({
495
744
  target_format: z2.enum(["word", "docx", "html", "pdf", "latex", "tex"]).optional().default("word"),
496
745
  output_subdir: z2.string().optional()
497
746
  });
498
- function formatToExtension(targetFormat) {
499
- switch (targetFormat) {
500
- case "word":
501
- case "docx":
502
- return ".docx";
503
- case "html":
504
- return ".html";
505
- case "pdf":
506
- return ".pdf";
507
- case "latex":
508
- case "tex":
509
- return ".tex";
510
- default:
511
- return ".out";
512
- }
513
- }
514
747
  function normalizeFormat(targetFormat) {
515
748
  switch (targetFormat) {
516
749
  case "word":
@@ -559,17 +792,20 @@ async function convertHandler(args, ctx) {
559
792
  const subdir = args.output_subdir || taskId;
560
793
  const outputRoot = resolve(ctx.config.outputDir, subdir);
561
794
  mkdirSync(outputRoot, { recursive: true });
562
- const outExt = formatToExtension(args.target_format);
563
- const outputPath = join(outputRoot, `result${outExt}`);
564
- const ws = createWriteStream(outputPath);
565
- await client.download(taskId, ws);
795
+ const tempPath = join2(outputRoot, "download.bin");
796
+ const ws = createWriteStream(tempPath);
797
+ await client.download(taskId, ws, { destPath: tempPath });
798
+ const kind = await sniffFile(tempPath);
799
+ const outputPath = join2(outputRoot, `result${extensionForKind(kind)}`);
800
+ await rename2(tempPath, outputPath);
566
801
  const output = {
567
802
  task_id: taskId,
568
803
  points_deducted: submitResult.points_deducted,
569
804
  remaining_points: submitResult.remaining_points,
570
805
  output: {
571
806
  output_path: outputPath,
572
- target_format: normalizeFormat(args.target_format)
807
+ target_format: normalizeFormat(args.target_format),
808
+ kind
573
809
  }
574
810
  };
575
811
  return jsonResult(output);
@@ -639,23 +875,40 @@ async function getTaskStatusHandler(args, ctx) {
639
875
 
640
876
  // src/tools/parse-pdf.ts
641
877
  import { createWriteStream as createWriteStream3, mkdirSync as mkdirSync3 } from "fs";
642
- import { readFile as readFile3 } from "fs/promises";
643
- import { basename as basename2, join as join3, resolve as resolve3 } from "path";
878
+ import { readFile as readFile3, rename as rename3 } from "fs/promises";
879
+ import { basename as basename2, join as join4, resolve as resolve3 } from "path";
644
880
  import { z as z5 } from "zod";
645
881
 
646
882
  // src/extract.ts
647
- import { createWriteStream as createWriteStream2, mkdirSync as mkdirSync2 } from "fs";
648
- import { dirname, join as join2 } from "path";
883
+ import { createWriteStream as createWriteStream2, mkdirSync as mkdirSync2, readFileSync } from "fs";
884
+ import { dirname, join as join3 } from "path";
649
885
  import { pipeline as pipeline2 } from "stream/promises";
650
886
  import { open as yauzlOpen } from "yauzl";
887
+ function pickPrimaryMarkdownPath(candidates) {
888
+ if (candidates.length === 0) return null;
889
+ const scored = candidates.map((c) => {
890
+ const base = (c.entryName.split("/").pop() || c.entryName).toLowerCase();
891
+ let score = c.size;
892
+ if (/^(outline|summary|verification_report|enrichment_meta|tables_changelog|tables_normalized)(\.|$)/i.test(
893
+ base
894
+ ) || /outline|summary|verification|enrichment|tables_/.test(base)) {
895
+ score -= 1e12;
896
+ }
897
+ if (base === "readme.md") score -= 1e9;
898
+ if (/translated|bilingual/.test(base)) score -= 1e6;
899
+ return { path: c.path, score };
900
+ });
901
+ scored.sort((a, b) => b.score - a.score);
902
+ return scored[0]?.path ?? null;
903
+ }
651
904
  async function extractZip(zipPath, destDir) {
652
905
  mkdirSync2(destDir, { recursive: true });
653
906
  const zipFile = await openZip(zipPath);
654
907
  const files = [];
655
- let markdownPath = null;
908
+ const mdCandidates = [];
656
909
  let imagesDir = null;
657
910
  for await (const entry of iterEntries(zipFile)) {
658
- const entryPath = join2(destDir, entry.fileName);
911
+ const entryPath = join3(destDir, entry.fileName);
659
912
  if (entry.fileName.endsWith("/")) {
660
913
  mkdirSync2(entryPath, { recursive: true });
661
914
  if (entry.fileName.includes("images")) {
@@ -668,14 +921,20 @@ async function extractZip(zipPath, destDir) {
668
921
  const writeStream = createWriteStream2(entryPath);
669
922
  await pipeline2(readStream, writeStream);
670
923
  files.push(entryPath);
671
- if (!markdownPath && /\.md$/i.test(entry.fileName)) {
672
- markdownPath = entryPath;
924
+ if (/\.md$/i.test(entry.fileName)) {
925
+ let size = entry.uncompressedSize || 0;
926
+ try {
927
+ size = readFileSync(entryPath).byteLength;
928
+ } catch {
929
+ }
930
+ mdCandidates.push({ path: entryPath, entryName: entry.fileName, size });
673
931
  }
674
932
  if (!imagesDir && /images\//i.test(entry.fileName)) {
675
933
  const prefix = entry.fileName.split("images/")[0] ?? "";
676
- imagesDir = join2(destDir, prefix, "images");
934
+ imagesDir = join3(destDir, prefix, "images");
677
935
  }
678
936
  }
937
+ const markdownPath = pickPrimaryMarkdownPath(mdCandidates);
679
938
  return { markdownPath, imagesDir, outputRoot: destDir, files };
680
939
  }
681
940
  function openZip(path) {
@@ -728,7 +987,7 @@ function openReadStream(zipFile, entry) {
728
987
 
729
988
  // src/tools/parse-pdf.ts
730
989
  var parsePdfName = "kolmopdf_parse_pdf";
731
- var parsePdfDescription = "Parse a local PDF into Markdown via KolmoPDF. Handles formulas, tables, multi-column layouts, and code blocks. Optionally translates while parsing.";
990
+ var parsePdfDescription = "Parse a local PDF into Markdown via KolmoPDF. Handles formulas, tables, multi-column layouts, and code blocks. Optionally translates while parsing. Server may attach outline.md/summary.md sidecars (ZIP download).";
732
991
  var parsePdfInputSchema = z5.object({
733
992
  file_path: z5.string().describe("Absolute or cwd-relative path to a local PDF file."),
734
993
  table_mode: z5.enum(["markdown", "image"]).optional(),
@@ -739,6 +998,9 @@ var parsePdfInputSchema = z5.object({
739
998
  images_as_url: z5.boolean().optional(),
740
999
  skip_rotation_detection: z5.boolean().optional(),
741
1000
  enable_cross_page_merge: z5.boolean().optional(),
1001
+ enrichment: z5.string().optional().describe(
1002
+ "Parse-time AI sidecars. Omit for server default outline,summary. Use 'none' to disable. Examples: outline,summary,verification"
1003
+ ),
742
1004
  output_subdir: z5.string().optional().describe("Subdirectory name under KOLMOPDF_OUTPUT_DIR. Defaults to <task_id>.")
743
1005
  });
744
1006
  async function parsePdfHandler(args, ctx) {
@@ -765,7 +1027,8 @@ async function parsePdfHandler(args, ctx) {
765
1027
  output_options: args.output_options,
766
1028
  images_as_url: args.images_as_url,
767
1029
  skip_rotation_detection: args.skip_rotation_detection,
768
- enable_cross_page_merge: args.enable_cross_page_merge
1030
+ enable_cross_page_merge: args.enable_cross_page_merge,
1031
+ enrichment: args.enrichment
769
1032
  },
770
1033
  filename
771
1034
  );
@@ -784,23 +1047,24 @@ async function parsePdfHandler(args, ctx) {
784
1047
  const subdir = args.output_subdir || taskId;
785
1048
  const outputRoot = resolve3(ctx.config.outputDir, subdir);
786
1049
  mkdirSync3(outputRoot, { recursive: true });
787
- const isUrlMode = args.images_as_url === true;
1050
+ const downloadPath = join4(outputRoot, "download.bin");
1051
+ const ws = createWriteStream3(downloadPath);
1052
+ await client.download(taskId, ws, { destPath: downloadPath });
788
1053
  let markdownPath;
789
1054
  let imagesDir = null;
790
1055
  let outputType;
791
- if (isUrlMode) {
792
- markdownPath = join3(outputRoot, "result.md");
793
- const ws = createWriteStream3(markdownPath);
794
- await client.download(taskId, ws);
795
- outputType = "markdown_file";
796
- } else {
797
- const zipPath = join3(outputRoot, "result.zip");
798
- const ws = createWriteStream3(zipPath);
799
- await client.download(taskId, ws);
1056
+ const kind = await sniffFile(downloadPath);
1057
+ if (kind === "zip") {
1058
+ const zipPath = join4(outputRoot, "result.zip");
1059
+ await rename3(downloadPath, zipPath);
800
1060
  const extracted = await extractZip(zipPath, outputRoot);
801
- markdownPath = extracted.markdownPath || join3(outputRoot, "result.md");
1061
+ markdownPath = extracted.markdownPath || join4(outputRoot, "result.md");
802
1062
  imagesDir = extracted.imagesDir;
803
1063
  outputType = "zip_extracted";
1064
+ } else {
1065
+ markdownPath = join4(outputRoot, "result.md");
1066
+ await rename3(downloadPath, markdownPath);
1067
+ outputType = "markdown_file";
804
1068
  }
805
1069
  const mdContent = await readFile3(markdownPath, "utf-8").catch(() => "");
806
1070
  const preview = mdContent.slice(0, 500);
@@ -824,11 +1088,11 @@ async function parsePdfHandler(args, ctx) {
824
1088
 
825
1089
  // src/tools/translate-pdf.ts
826
1090
  import { createWriteStream as createWriteStream4, mkdirSync as mkdirSync4 } from "fs";
827
- import { readFile as readFile4 } from "fs/promises";
828
- import { basename as basename3, join as join4, resolve as resolve4 } from "path";
1091
+ import { readFile as readFile4, rename as rename4 } from "fs/promises";
1092
+ import { basename as basename3, join as join5, resolve as resolve4 } from "path";
829
1093
  import { z as z6 } from "zod";
830
1094
  var translatePdfName = "kolmopdf_translate_pdf";
831
- var translatePdfDescription = "Translate a PDF while preserving its original layout via KolmoPDF. Produces a translated PDF (optionally side-by-side bilingual).";
1095
+ var translatePdfDescription = "Translate a PDF while preserving its original layout via KolmoPDF. Produces a translated PDF, or a ZIP of PDFs when multiple layout modes are requested.";
832
1096
  var translatePdfInputSchema = z6.object({
833
1097
  file_path: z6.string(),
834
1098
  source_language: z6.string().optional().default("en"),
@@ -874,13 +1138,23 @@ async function translatePdfHandler(args, ctx) {
874
1138
  },
875
1139
  progress: ctx.progress
876
1140
  });
877
- await ctx.progress?.report("[downloading] Fetching translated PDF...");
1141
+ await ctx.progress?.report("[downloading] Fetching translated result...");
878
1142
  const subdir = args.output_subdir || taskId;
879
1143
  const outputRoot = resolve4(ctx.config.outputDir, subdir);
880
1144
  mkdirSync4(outputRoot, { recursive: true });
881
- const pdfPath = join4(outputRoot, "translated.pdf");
882
- const ws = createWriteStream4(pdfPath);
883
- await client.download(taskId, ws);
1145
+ const tempPath = join5(outputRoot, "download.bin");
1146
+ const ws = createWriteStream4(tempPath);
1147
+ await client.download(taskId, ws, { destPath: tempPath });
1148
+ const kind = await sniffFile(tempPath);
1149
+ let translatedPdfPath = join5(outputRoot, `translated${extensionForKind(kind)}`);
1150
+ let archivePath;
1151
+ await rename4(tempPath, translatedPdfPath);
1152
+ if (kind === "zip") {
1153
+ archivePath = translatedPdfPath;
1154
+ const extracted = await extractZip(archivePath, outputRoot);
1155
+ const pdfs = extracted.files.filter((f) => f.toLowerCase().endsWith(".pdf"));
1156
+ if (pdfs[0]) translatedPdfPath = pdfs[0];
1157
+ }
884
1158
  const pagesTranslated = Math.round(submitResult.points_deducted / 2);
885
1159
  const output = {
886
1160
  task_id: taskId,
@@ -888,14 +1162,16 @@ async function translatePdfHandler(args, ctx) {
888
1162
  points_deducted: submitResult.points_deducted,
889
1163
  remaining_points: submitResult.remaining_points,
890
1164
  output: {
891
- translated_pdf_path: pdfPath
1165
+ kind,
1166
+ translated_pdf_path: translatedPdfPath,
1167
+ ...archivePath ? { archive_path: archivePath } : {}
892
1168
  }
893
1169
  };
894
1170
  return jsonResult(output);
895
1171
  }
896
1172
 
897
1173
  // src/index.ts
898
- var VERSION = "1.0.0";
1174
+ var VERSION = "1.1.0";
899
1175
  function buildContext() {
900
1176
  const config = loadConfig();
901
1177
  return {