@exactpdf/mcp 0.2.3 → 0.2.6

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/README.md CHANGED
@@ -55,6 +55,45 @@ For a local checkout of this monorepo:
55
55
  | `exactpdf_images_to_pdf` | POST /api/v1/images-to-pdf | 1 |
56
56
  | `exactpdf_extract_text` | POST /api/v1/extract-text | 1 |
57
57
  | `exactpdf_pdf_structured_markdown` | POST /api/v1/pdf-structured-markdown | 1 |
58
+ | `exactpdf_estimate_speech_cost` | local estimate | 0 |
59
+ | `exactpdf_pdf_to_speech` | POST /api/v1/pdf-to-speech | 10 |
60
+ | `exactpdf_pdf_to_audiobook` | POST /api/v1/pdf-to-audiobook | 10 |
61
+ | `exactpdf_generate_audiobook` | POST /api/v1/generate-audiobook | 10 |
62
+ | `exactpdf_translate_and_speak` | POST /api/v1/translate-and-speak | 20 |
63
+ | `exactpdf_presentation_narration` | POST /api/v1/presentation-narration | 10 |
64
+ | `exactpdf_job_status` | GET /api/v1/jobs/:id | 0 |
65
+ | `exactpdf_get_speech_job` | GET /api/v1/speech-jobs/:id | 0 |
66
+ | `exactpdf_download_audio` | GET /api/v1/speech-jobs/:id/download | 0 |
67
+
68
+ Async audiobook flow:
69
+
70
+ ```text
71
+ 1. exactpdf_estimate_speech_cost(path="/abs/book.pdf", mode="audiobook")
72
+ 2. exactpdf_generate_audiobook(path="/abs/book.pdf", voice_style="audiobook")
73
+ 3. exactpdf_get_speech_job(job_id="...", download=false)
74
+ 4. exactpdf_download_audio(job_id="...") after status is succeeded
75
+ ```
76
+
77
+ `exactpdf_pdf_to_speech`, `exactpdf_pdf_to_audiobook`, and `exactpdf_generate_audiobook` accept optional `callback_url` and `webhook_secret`. ExactPDF signs webhook bodies with `x-exactpdf-signature: sha256=<hmac>` over `timestamp.body`.
78
+
79
+ Multilingual speech flow:
80
+
81
+ ```text
82
+ 1. exactpdf_translate_and_speak(path="/abs/training.pdf", target_language="hi")
83
+ 2. exactpdf_job_status(job_id="...", download=false)
84
+ 3. exactpdf_job_status(job_id="...", download=true) after status is succeeded
85
+ ```
86
+
87
+ `segmentation_mode="pages"` is useful for presentation narration because each PDF page becomes a trackable audio section.
88
+
89
+ Presentation narration flow:
90
+
91
+ ```text
92
+ 1. exactpdf_presentation_narration(path="/abs/deck.pdf")
93
+ 2. exactpdf_job_status(job_id="...", download=true) after status is succeeded
94
+ ```
95
+
96
+ The default ZIP includes one MP3 per PDF page plus `manifest.json` with page numbers and duration seconds.
58
97
 
59
98
  ## Publish checklist
60
99
 
package/dist/run.js CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @see https://exactpdf.com/docs/api
7
7
  */
8
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
8
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { basename, join } from 'node:path';
11
11
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -24,9 +24,29 @@ function requireKey() {
24
24
  }
25
25
  return k;
26
26
  }
27
- const server = new McpServer({ name: 'exactpdf', version: '0.2.3' }, {
28
- instructions: 'ExactPDF API tools: exactpdf_account + exactpdf_pdf_info (free); merge, split, rotate, compress, images→PDF, extract-text, pdf-structured-markdown (1 credit each on success). Set EXACTPDF_API_KEY.',
27
+ const server = new McpServer({ name: 'exactpdf', version: '0.2.6' }, {
28
+ instructions: 'ExactPDF API tools: exactpdf_account + exactpdf_pdf_info (free); merge, split, rotate, compress, images→PDF, extract-text, pdf-structured-markdown (1 credit each on success); async pdf-to-speech/pdf-to-audiobook/generate-audiobook (10 credits), translate-and-speak (20 credits), presentation narration (10 credits), plus speech job polling/download. Set EXACTPDF_API_KEY.',
29
29
  });
30
+ async function saveBinaryFromResponse(res, prefix, fallbackExt) {
31
+ const buf = Buffer.from(await res.arrayBuffer());
32
+ const dir = outputDir();
33
+ await mkdir(dir, { recursive: true });
34
+ const outPath = join(dir, `${prefix}-${Date.now()}.${fallbackExt}`);
35
+ await writeFile(outPath, buf);
36
+ return outPath;
37
+ }
38
+ function extensionFromContentType(contentType, fallback) {
39
+ const ct = contentType?.toLowerCase() ?? '';
40
+ if (ct.includes('audio/mpeg') || ct.includes('audio/mp3'))
41
+ return 'mp3';
42
+ if (ct.includes('audio/wav') || ct.includes('audio/x-wav'))
43
+ return 'wav';
44
+ if (ct.includes('zip'))
45
+ return 'zip';
46
+ if (ct.includes('pdf'))
47
+ return 'pdf';
48
+ return fallback;
49
+ }
30
50
  server.registerTool('exactpdf_account', {
31
51
  description: 'Return ExactPDF API credit balance and key metadata. Does not consume a credit.',
32
52
  inputSchema: z.object({}),
@@ -377,6 +397,443 @@ server.registerTool('exactpdf_pdf_structured_markdown', {
377
397
  content: [{ type: 'text', text: `Credits remaining: ${credits}\n${raw.slice(0, 120_000)}` }],
378
398
  };
379
399
  });
400
+ server.registerTool('exactpdf_pdf_to_audiobook', {
401
+ description: 'Create an async PDF→audiobook job via ExactPDF API (10 credits). Returns a job_id; poll with exactpdf_job_status.',
402
+ inputSchema: z.object({
403
+ path: z.string().describe('Absolute path to a PDF file'),
404
+ output_format: z.enum(['mp3', 'wav', 'zip']).optional().describe('Audio export format. Default: mp3.'),
405
+ voice_style: z
406
+ .enum(['professional', 'audiobook', 'educational', 'presenter', 'conversational'])
407
+ .optional()
408
+ .describe('Narration style. Default: audiobook.'),
409
+ voice_id: z.string().optional().describe('Optional provider voice id, if your account supports it.'),
410
+ language: z.string().optional().describe('Language hint such as en, hi, es, fr.'),
411
+ speed: z.number().min(0.5).max(2).optional().describe('Speech speed multiplier, 0.5-2.0.'),
412
+ page_range: z.string().optional().describe('Optional page range, e.g. "1-10" or "2,4-7".'),
413
+ normalize_pauses: z.boolean().optional().describe('Normalize pauses between extracted sections.'),
414
+ preserve_chapters: z.boolean().optional().describe('Keep detected headings as audiobook chapters.'),
415
+ pronunciation_fixes: z.string().optional().describe('JSON object of pronunciation replacements.'),
416
+ callback_url: z.string().url().optional().describe('Optional https webhook URL for job completion.'),
417
+ webhook_secret: z.string().min(12).max(256).optional().describe('Optional webhook HMAC signing secret.'),
418
+ }),
419
+ }, async ({ path, output_format, voice_style, voice_id, language, speed, page_range, normalize_pauses, preserve_chapters, pronunciation_fixes, callback_url, webhook_secret, }) => {
420
+ const key = requireKey();
421
+ const form = new FormData();
422
+ form.append('file', new Blob([await readFile(path)], { type: 'application/pdf' }), basename(path));
423
+ if (output_format)
424
+ form.append('output_format', output_format);
425
+ if (voice_style)
426
+ form.append('voice_style', voice_style);
427
+ if (voice_id)
428
+ form.append('voice_id', voice_id);
429
+ if (language)
430
+ form.append('language', language);
431
+ if (typeof speed === 'number')
432
+ form.append('speed', String(speed));
433
+ if (page_range)
434
+ form.append('page_range', page_range);
435
+ if (typeof normalize_pauses === 'boolean')
436
+ form.append('normalize_pauses', String(normalize_pauses));
437
+ if (typeof preserve_chapters === 'boolean')
438
+ form.append('preserve_chapters', String(preserve_chapters));
439
+ if (pronunciation_fixes)
440
+ form.append('pronunciation_fixes', pronunciation_fixes);
441
+ if (callback_url)
442
+ form.append('callback_url', callback_url);
443
+ if (webhook_secret)
444
+ form.append('webhook_secret', webhook_secret);
445
+ const res = await fetch(`${BASE}/api/v1/pdf-to-audiobook`, {
446
+ method: 'POST',
447
+ headers: {
448
+ Authorization: `Bearer ${key}`,
449
+ Accept: 'application/json',
450
+ },
451
+ body: form,
452
+ });
453
+ const raw = await res.text();
454
+ if (!res.ok) {
455
+ return {
456
+ content: [
457
+ {
458
+ type: 'text',
459
+ text: `pdf-to-audiobook failed HTTP ${res.status}\n${raw.slice(0, 8000)}`,
460
+ },
461
+ ],
462
+ isError: true,
463
+ };
464
+ }
465
+ return {
466
+ content: [
467
+ {
468
+ type: 'text',
469
+ text: `Audiobook job submitted HTTP ${res.status}\n${raw.slice(0, 120_000)}`,
470
+ },
471
+ ],
472
+ };
473
+ });
474
+ server.registerTool('exactpdf_job_status', {
475
+ description: 'Poll an ExactPDF async API job. Set download=true to save the result when the job has succeeded.',
476
+ inputSchema: z.object({
477
+ job_id: z.string().describe('ExactPDF async job id returned by exactpdf_pdf_to_audiobook.'),
478
+ download: z.boolean().optional().describe('When true, save result_url to EXACTPDF_API_OUTPUT_DIR.'),
479
+ }),
480
+ }, async ({ job_id, download }) => {
481
+ const key = requireKey();
482
+ const res = await fetch(`${BASE}/api/v1/jobs/${encodeURIComponent(job_id)}`, {
483
+ headers: {
484
+ Authorization: `Bearer ${key}`,
485
+ Accept: 'application/json',
486
+ },
487
+ });
488
+ const raw = await res.text();
489
+ if (!res.ok) {
490
+ return {
491
+ content: [
492
+ {
493
+ type: 'text',
494
+ text: `job-status failed HTTP ${res.status}\n${raw.slice(0, 8000)}`,
495
+ },
496
+ ],
497
+ isError: true,
498
+ };
499
+ }
500
+ let saved = '';
501
+ if (download) {
502
+ const parsed = JSON.parse(raw);
503
+ const resultUrl = parsed.job?.result_url;
504
+ if (parsed.job?.status === 'succeeded' && resultUrl) {
505
+ const fileRes = await fetch(resultUrl);
506
+ if (!fileRes.ok) {
507
+ return {
508
+ content: [
509
+ {
510
+ type: 'text',
511
+ text: `Job succeeded, but result download failed HTTP ${fileRes.status}\n${raw.slice(0, 8000)}`,
512
+ },
513
+ ],
514
+ isError: true,
515
+ };
516
+ }
517
+ const fallback = parsed.job?.kind === 'audiobook' ? 'mp3' : 'pdf';
518
+ const ext = extensionFromContentType(fileRes.headers.get('content-type'), fallback);
519
+ saved = `\nSaved result: ${await saveBinaryFromResponse(fileRes, `exactpdf-job-${job_id}`, ext)}`;
520
+ }
521
+ }
522
+ return {
523
+ content: [{ type: 'text', text: `HTTP ${res.status}\n${raw.slice(0, 120_000)}${saved}` }],
524
+ };
525
+ });
526
+ server.registerTool('exactpdf_translate_and_speak', {
527
+ description: 'Create an async PDF translation + native speech job via ExactPDF API (20 credits). Start with target_language hi, es, or fr; poll with exactpdf_job_status.',
528
+ inputSchema: z.object({
529
+ path: z.string().describe('Absolute path to a PDF file'),
530
+ target_language: z.string().describe('Target language code, e.g. hi, es, fr, mr, de, ja, ar.'),
531
+ source_language: z.string().optional().describe('Source language code. Default: auto.'),
532
+ output_format: z.enum(['mp3', 'wav', 'zip']).optional().describe('Audio export format. Default: mp3.'),
533
+ voice_style: z
534
+ .enum(['professional', 'audiobook', 'educational', 'presenter', 'conversational'])
535
+ .optional()
536
+ .describe('Narration style. Default: audiobook.'),
537
+ speed: z.number().min(0.5).max(2).optional().describe('Speech speed multiplier, 0.5-2.0.'),
538
+ page_range: z.string().optional().describe('Optional page range, e.g. "1-10" or "2,4-7".'),
539
+ segmentation_mode: z.enum(['chapters', 'pages']).optional().describe('Use pages for slide/page narration.'),
540
+ translation_glossary: z.string().optional().describe('JSON array like [{"source":"ExactPDF","target":"ExactPDF"}].'),
541
+ callback_url: z.string().url().optional().describe('Optional https webhook URL for job completion.'),
542
+ webhook_secret: z.string().min(12).max(256).optional().describe('Optional webhook HMAC signing secret.'),
543
+ }),
544
+ }, async ({ path, target_language, source_language, output_format, voice_style, speed, page_range, segmentation_mode, translation_glossary, callback_url, webhook_secret, }) => {
545
+ const key = requireKey();
546
+ const form = new FormData();
547
+ form.append('file', new Blob([await readFile(path)], { type: 'application/pdf' }), basename(path));
548
+ form.append('target_language', target_language);
549
+ if (source_language)
550
+ form.append('source_language', source_language);
551
+ if (output_format)
552
+ form.append('output_format', output_format);
553
+ if (voice_style)
554
+ form.append('voice_style', voice_style);
555
+ if (typeof speed === 'number')
556
+ form.append('speed', String(speed));
557
+ if (page_range)
558
+ form.append('page_range', page_range);
559
+ if (segmentation_mode)
560
+ form.append('segmentation_mode', segmentation_mode);
561
+ if (translation_glossary)
562
+ form.append('translation_glossary', translation_glossary);
563
+ if (callback_url)
564
+ form.append('callback_url', callback_url);
565
+ if (webhook_secret)
566
+ form.append('webhook_secret', webhook_secret);
567
+ const res = await fetch(`${BASE}/api/v1/translate-and-speak`, {
568
+ method: 'POST',
569
+ headers: {
570
+ Authorization: `Bearer ${key}`,
571
+ Accept: 'application/json',
572
+ },
573
+ body: form,
574
+ });
575
+ const raw = await res.text();
576
+ if (!res.ok) {
577
+ return {
578
+ content: [
579
+ {
580
+ type: 'text',
581
+ text: `translate-and-speak failed HTTP ${res.status}\n${raw.slice(0, 8000)}`,
582
+ },
583
+ ],
584
+ isError: true,
585
+ };
586
+ }
587
+ return {
588
+ content: [
589
+ {
590
+ type: 'text',
591
+ text: `Multilingual speech job submitted HTTP ${res.status}\n${raw.slice(0, 120_000)}`,
592
+ },
593
+ ],
594
+ };
595
+ });
596
+ server.registerTool('exactpdf_presentation_narration', {
597
+ description: 'Create an audio-first PDF presentation narration job (10 credits). Each page becomes a narrated slide track in a ZIP manifest.',
598
+ inputSchema: z.object({
599
+ path: z.string().describe('Absolute path to a PDF presentation file'),
600
+ output_format: z.enum(['mp3', 'wav', 'zip']).optional().describe('Default: zip for per-slide tracks and manifest.'),
601
+ voice_style: z
602
+ .enum(['professional', 'audiobook', 'educational', 'presenter', 'conversational'])
603
+ .optional()
604
+ .describe('Narration style. Default: presenter.'),
605
+ speed: z.number().min(0.5).max(2).optional().describe('Speech speed multiplier, 0.5-2.0.'),
606
+ page_range: z.string().optional().describe('Optional slide/page range, e.g. "1-10" or "2,4-7".'),
607
+ callback_url: z.string().url().optional().describe('Optional https webhook URL for job completion.'),
608
+ webhook_secret: z.string().min(12).max(256).optional().describe('Optional webhook HMAC signing secret.'),
609
+ }),
610
+ }, async ({ path, output_format, voice_style, speed, page_range, callback_url, webhook_secret }) => {
611
+ const key = requireKey();
612
+ const form = new FormData();
613
+ form.append('file', new Blob([await readFile(path)], { type: 'application/pdf' }), basename(path));
614
+ if (output_format)
615
+ form.append('output_format', output_format);
616
+ if (voice_style)
617
+ form.append('voice_style', voice_style);
618
+ if (typeof speed === 'number')
619
+ form.append('speed', String(speed));
620
+ if (page_range)
621
+ form.append('page_range', page_range);
622
+ if (callback_url)
623
+ form.append('callback_url', callback_url);
624
+ if (webhook_secret)
625
+ form.append('webhook_secret', webhook_secret);
626
+ const res = await fetch(`${BASE}/api/v1/presentation-narration`, {
627
+ method: 'POST',
628
+ headers: {
629
+ Authorization: `Bearer ${key}`,
630
+ Accept: 'application/json',
631
+ },
632
+ body: form,
633
+ });
634
+ const raw = await res.text();
635
+ if (!res.ok) {
636
+ return {
637
+ content: [
638
+ {
639
+ type: 'text',
640
+ text: `presentation-narration failed HTTP ${res.status}\n${raw.slice(0, 8000)}`,
641
+ },
642
+ ],
643
+ isError: true,
644
+ };
645
+ }
646
+ return {
647
+ content: [
648
+ {
649
+ type: 'text',
650
+ text: `Presentation narration job submitted HTTP ${res.status}\n${raw.slice(0, 120_000)}`,
651
+ },
652
+ ],
653
+ };
654
+ });
655
+ const speechJobSchema = z.object({
656
+ path: z.string().describe('Absolute path to a PDF file'),
657
+ output_format: z.enum(['mp3', 'wav', 'zip']).optional().describe('Audio export format. Default: mp3.'),
658
+ voice_style: z
659
+ .enum(['professional', 'audiobook', 'educational', 'presenter', 'conversational'])
660
+ .optional()
661
+ .describe('Narration style.'),
662
+ voice_id: z.string().optional().describe('Optional provider voice id, if your account supports it.'),
663
+ language: z.string().optional().describe('Language hint such as en, hi, es, fr.'),
664
+ speed: z.number().min(0.5).max(2).optional().describe('Speech speed multiplier, 0.5-2.0.'),
665
+ page_range: z.string().optional().describe('Optional page range, e.g. "1-10" or "2,4-7".'),
666
+ normalize_pauses: z.boolean().optional().describe('Normalize pauses between extracted sections.'),
667
+ preserve_chapters: z.boolean().optional().describe('Keep detected headings as audiobook chapters.'),
668
+ pronunciation_fixes: z.string().optional().describe('JSON array of pronunciation replacements.'),
669
+ callback_url: z.string().url().optional().describe('Optional https webhook URL for job completion.'),
670
+ webhook_secret: z.string().min(12).max(256).optional().describe('Optional webhook HMAC signing secret.'),
671
+ });
672
+ async function submitSpeechJob(endpoint, args, label) {
673
+ const key = requireKey();
674
+ const form = new FormData();
675
+ form.append('file', new Blob([await readFile(args.path)], { type: 'application/pdf' }), basename(args.path));
676
+ if (args.output_format)
677
+ form.append('output_format', args.output_format);
678
+ if (args.voice_style)
679
+ form.append('voice_style', args.voice_style);
680
+ if (args.voice_id)
681
+ form.append('voice_id', args.voice_id);
682
+ if (args.language)
683
+ form.append('language', args.language);
684
+ if (typeof args.speed === 'number')
685
+ form.append('speed', String(args.speed));
686
+ if (args.page_range)
687
+ form.append('page_range', args.page_range);
688
+ if (typeof args.normalize_pauses === 'boolean')
689
+ form.append('normalize_pauses', String(args.normalize_pauses));
690
+ if (typeof args.preserve_chapters === 'boolean')
691
+ form.append('preserve_chapters', String(args.preserve_chapters));
692
+ if (args.pronunciation_fixes)
693
+ form.append('pronunciation_fixes', args.pronunciation_fixes);
694
+ if (args.callback_url)
695
+ form.append('callback_url', args.callback_url);
696
+ if (args.webhook_secret)
697
+ form.append('webhook_secret', args.webhook_secret);
698
+ const res = await fetch(`${BASE}${endpoint}`, {
699
+ method: 'POST',
700
+ headers: {
701
+ Authorization: `Bearer ${key}`,
702
+ Accept: 'application/json',
703
+ },
704
+ body: form,
705
+ });
706
+ const raw = await res.text();
707
+ if (!res.ok) {
708
+ return {
709
+ content: [{ type: 'text', text: `${label} failed HTTP ${res.status}\n${raw.slice(0, 8000)}` }],
710
+ isError: true,
711
+ };
712
+ }
713
+ return {
714
+ content: [{ type: 'text', text: `${label} job submitted HTTP ${res.status}\n${raw.slice(0, 120_000)}` }],
715
+ };
716
+ }
717
+ async function pollSpeechJob(jobId, download, downloadEndpoint = false) {
718
+ const key = requireKey();
719
+ const endpoint = downloadEndpoint
720
+ ? `/api/v1/speech-jobs/${encodeURIComponent(jobId)}/download`
721
+ : `/api/v1/speech-jobs/${encodeURIComponent(jobId)}`;
722
+ const res = await fetch(`${BASE}${endpoint}`, {
723
+ redirect: downloadEndpoint ? 'manual' : 'follow',
724
+ headers: {
725
+ Authorization: `Bearer ${key}`,
726
+ Accept: downloadEndpoint ? '*/*' : 'application/json',
727
+ },
728
+ });
729
+ if (downloadEndpoint) {
730
+ const location = res.headers.get('location');
731
+ if (res.status >= 300 && res.status < 400 && location) {
732
+ const fileRes = await fetch(location);
733
+ if (!fileRes.ok) {
734
+ return {
735
+ content: [{ type: 'text', text: `download-audio signed URL failed HTTP ${fileRes.status}` }],
736
+ isError: true,
737
+ };
738
+ }
739
+ const ext = extensionFromContentType(fileRes.headers.get('content-type'), 'mp3');
740
+ return {
741
+ content: [{ type: 'text', text: `Saved audio: ${await saveBinaryFromResponse(fileRes, `exactpdf-audio-${jobId}`, ext)}` }],
742
+ };
743
+ }
744
+ const raw = await res.text();
745
+ return {
746
+ content: [{ type: 'text', text: `download-audio failed HTTP ${res.status}\n${raw.slice(0, 8000)}` }],
747
+ isError: true,
748
+ };
749
+ }
750
+ const raw = await res.text();
751
+ if (!res.ok) {
752
+ return {
753
+ content: [{ type: 'text', text: `get-speech-job failed HTTP ${res.status}\n${raw.slice(0, 8000)}` }],
754
+ isError: true,
755
+ };
756
+ }
757
+ let saved = '';
758
+ if (download) {
759
+ const parsed = JSON.parse(raw);
760
+ const resultUrl = parsed.job?.result_url;
761
+ if (parsed.job?.status === 'succeeded' && resultUrl) {
762
+ const fileRes = await fetch(resultUrl);
763
+ if (!fileRes.ok) {
764
+ return {
765
+ content: [{ type: 'text', text: `Job succeeded, but result download failed HTTP ${fileRes.status}\n${raw.slice(0, 8000)}` }],
766
+ isError: true,
767
+ };
768
+ }
769
+ const ext = extensionFromContentType(fileRes.headers.get('content-type'), 'mp3');
770
+ saved = `\nSaved result: ${await saveBinaryFromResponse(fileRes, `exactpdf-speech-job-${jobId}`, ext)}`;
771
+ }
772
+ }
773
+ return {
774
+ content: [{ type: 'text', text: `HTTP ${res.status}\n${raw.slice(0, 120_000)}${saved}` }],
775
+ };
776
+ }
777
+ server.registerTool('exactpdf_estimate_speech_cost', {
778
+ description: 'Estimate PDF speech duration and current ExactPDF API credit cost before submitting a paid job. No API credits consumed.',
779
+ inputSchema: z.object({
780
+ path: z.string().optional().describe('Absolute path to a PDF file. Used for a rough size-based duration estimate.'),
781
+ characters: z.number().int().positive().optional().describe('Known narration character count, if already extracted.'),
782
+ minutes: z.number().positive().optional().describe('Known generated minutes, if already estimated.'),
783
+ mode: z.enum(['speech', 'audiobook', 'presentation', 'translate']).optional().describe('Default: audiobook.'),
784
+ target_language: z.string().optional().describe('Set for translation + speech estimates.'),
785
+ }),
786
+ }, async ({ path, characters, minutes, mode, target_language }) => {
787
+ let estimatedChars = characters ?? 0;
788
+ let fileBytes = null;
789
+ if (!estimatedChars && path) {
790
+ const info = await stat(path);
791
+ fileBytes = info.size;
792
+ estimatedChars = Math.max(1_000, Math.round(info.size / 8));
793
+ }
794
+ const estimatedMinutes = minutes ?? Math.max(1, Math.ceil(estimatedChars / 900));
795
+ const selectedMode = target_language ? 'translate' : (mode ?? 'audiobook');
796
+ const credits = selectedMode === 'translate' ? 20 : 10;
797
+ return {
798
+ content: [
799
+ {
800
+ type: 'text',
801
+ text: JSON.stringify({
802
+ ok: true,
803
+ estimate: {
804
+ mode: selectedMode,
805
+ file_bytes: fileBytes,
806
+ characters: estimatedChars || null,
807
+ generated_minutes: estimatedMinutes,
808
+ current_credit_cost: credits,
809
+ note: 'ExactPDF currently charges fixed job credits for async speech jobs: 10 for speech/audiobook/presentation, 20 for translate-and-speak. Minute-based pricing is planned.',
810
+ },
811
+ }, null, 2),
812
+ },
813
+ ],
814
+ };
815
+ });
816
+ server.registerTool('exactpdf_pdf_to_speech', {
817
+ description: 'Create an async PDF→speech job via /api/v1/pdf-to-speech (10 credits). Alias of the production audiobook pipeline.',
818
+ inputSchema: speechJobSchema,
819
+ }, async (args) => submitSpeechJob('/api/v1/pdf-to-speech', args, 'pdf-to-speech'));
820
+ server.registerTool('exactpdf_generate_audiobook', {
821
+ description: 'Create an async audiobook job via /api/v1/generate-audiobook (10 credits). Alias of the production audiobook pipeline.',
822
+ inputSchema: speechJobSchema,
823
+ }, async (args) => submitSpeechJob('/api/v1/generate-audiobook', args, 'generate-audiobook'));
824
+ server.registerTool('exactpdf_get_speech_job', {
825
+ description: 'Poll /api/v1/speech-jobs/:id for async PDF speech, audiobook, translation, or presentation narration jobs.',
826
+ inputSchema: z.object({
827
+ job_id: z.string().describe('ExactPDF speech job id.'),
828
+ download: z.boolean().optional().describe('When true, save result_url to EXACTPDF_API_OUTPUT_DIR if job succeeded.'),
829
+ }),
830
+ }, async ({ job_id, download }) => pollSpeechJob(job_id, Boolean(download)));
831
+ server.registerTool('exactpdf_download_audio', {
832
+ description: 'Download a succeeded speech job through /api/v1/speech-jobs/:id/download and save it locally.',
833
+ inputSchema: z.object({
834
+ job_id: z.string().describe('ExactPDF speech job id.'),
835
+ }),
836
+ }, async ({ job_id }) => pollSpeechJob(job_id, true, true));
380
837
  async function main() {
381
838
  const transport = new StdioServerTransport();
382
839
  await server.connect(transport);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@exactpdf/mcp",
3
- "version": "0.2.3",
4
- "description": "MCP server for ExactPDF — PDF tools (merge, split, rotate, compress, images→PDF, metadata) and API credits.",
3
+ "version": "0.2.6",
4
+ "description": "MCP server for ExactPDF — PDF tools, async PDF to audiobook jobs, polling, and API credits.",
5
5
  "mcpName": "com.exactpdf/mcp",
6
6
  "type": "module",
7
7
  "main": "./dist/run.js",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "repository": {
29
29
  "type": "git",
30
- "url": "https://github.com/exactpdf/exactpdf.git",
30
+ "url": "git+https://github.com/exactpdf/exactpdf.git",
31
31
  "directory": "packages/exactpdf-mcp"
32
32
  },
33
33
  "scripts": {
package/server.json CHANGED
@@ -2,14 +2,14 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "com.exactpdf/mcp",
4
4
  "title": "ExactPDF",
5
- "description": "MCP server for ExactPDF's agent-facing PDF API: merge, split, rotate, compress, images to PDF, PDF metadata, text extraction, and structured Markdown export.",
6
- "version": "0.2.3",
5
+ "description": "Agent-facing PDF API: merge, split, rotate, compress, images, metadata, text, Markdown, async speech/audiobook, multilingual speech, and presentation narration jobs.",
6
+ "version": "0.2.6",
7
7
  "websiteUrl": "https://exactpdf.com/docs/api",
8
8
  "packages": [
9
9
  {
10
10
  "registryType": "npm",
11
11
  "identifier": "@exactpdf/mcp",
12
- "version": "0.2.3",
12
+ "version": "0.2.6",
13
13
  "transport": {
14
14
  "type": "stdio"
15
15
  }