@adeu/mcp-server 1.8.0 → 1.10.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
@@ -1,14 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
7
+ import { basename as basename2, resolve as resolve2, extname, dirname, join as join3 } from "path";
8
+ import { z } from "zod";
6
9
  import {
7
- CallToolRequestSchema,
8
- ListToolsRequestSchema
9
- } from "@modelcontextprotocol/sdk/types.js";
10
- import { readFileSync as readFileSync3 } from "fs";
11
- import { basename as basename2, resolve as resolve2, extname, dirname } from "path";
10
+ registerAppTool,
11
+ registerAppResource,
12
+ RESOURCE_MIME_TYPE
13
+ } from "@modelcontextprotocol/ext-apps/server";
14
+ import fs from "fs";
12
15
  import {
13
16
  identifyEngine,
14
17
  extractTextFromBuffer,
@@ -20,7 +23,7 @@ import {
20
23
  } from "@adeu/core";
21
24
 
22
25
  // src/response-builders.ts
23
- import { resolve } from "path";
26
+ import { resolve, basename } from "path";
24
27
  import {
25
28
  paginate,
26
29
  split_structural_appendix,
@@ -66,7 +69,8 @@ Document has ${nodes.length} headings, all at deeper levels. Call read_docx with
66
69
  if (verbose) {
67
70
  const meta_parts = [`p${node.page}`, node.style];
68
71
  if (node.has_table) meta_parts.push("has table");
69
- if (node.footnote_ids && node.footnote_ids.length > 0) meta_parts.push("fn:" + node.footnote_ids.join(","));
72
+ if (node.footnote_ids && node.footnote_ids.length > 0)
73
+ meta_parts.push("fn:" + node.footnote_ids.join(","));
70
74
  lines.push(`${prefix} ${node.text} (${meta_parts.join(", ")})`);
71
75
  } else {
72
76
  lines.push(`${prefix} ${node.text} (p${node.page})`);
@@ -79,18 +83,30 @@ function build_paginated_response(text, page, file_path) {
79
83
  const has_appendix = Boolean(appendix.trim());
80
84
  const result = paginate(body, "");
81
85
  if (page < 1 || page > result.total_pages) {
82
- throw new Error(`Page ${page} out of range (doc has ${result.total_pages} pages).`);
86
+ throw new Error(
87
+ `Page ${page} out of range (doc has ${result.total_pages} pages).`
88
+ );
83
89
  }
84
90
  const selected = result.pages[page - 1];
85
91
  const banner = _build_page_banner(selected.page, selected.total_pages);
86
- const footer = _build_page_footer(selected.page, selected.total_pages, selected.has_next);
92
+ const footer = _build_page_footer(
93
+ selected.page,
94
+ selected.total_pages,
95
+ selected.has_next
96
+ );
87
97
  const appendix_pointer = _build_appendix_pointer(has_appendix);
88
98
  const ui_markdown = banner + selected.page_content + footer + appendix_pointer;
89
99
  const llm_content = `> **File Path:** \`${resolve(file_path)}\`
90
100
 
91
101
  ${ui_markdown}`;
92
102
  return {
93
- content: [{ type: "text", text: llm_content }]
103
+ content: [{ type: "text", text: llm_content }],
104
+ // Include structuredContent for the UI to render the markdown
105
+ structuredContent: {
106
+ markdown: ui_markdown,
107
+ file_path: resolve(file_path),
108
+ title: basename(file_path)
109
+ }
94
110
  };
95
111
  }
96
112
  function build_outline_response(doc, projected_text, file_path, outline_max_level = 2, outline_verbose = false) {
@@ -102,8 +118,14 @@ function build_outline_response(doc, projected_text, file_path, outline_max_leve
102
118
  pagination_result.body_pages,
103
119
  pagination_result.body_page_offsets
104
120
  );
105
- const rendered = render_outline_tree(nodes, outline_max_level, outline_verbose);
106
- const visible_count = nodes.filter((n) => n.level <= outline_max_level).length;
121
+ const rendered = render_outline_tree(
122
+ nodes,
123
+ outline_max_level,
124
+ outline_verbose
125
+ );
126
+ const visible_count = nodes.filter(
127
+ (n) => n.level <= outline_max_level
128
+ ).length;
107
129
  const deeper_count = nodes.length - visible_count;
108
130
  const deeper_hint = deeper_count > 0 ? ` (${deeper_count} more at deeper levels, raise outline_max_level to see)` : "";
109
131
  const header = `> **Outline view** \u2014 showing ${visible_count} of ${nodes.length} headings (L1-L${outline_max_level}${deeper_hint}) across ${pagination_result.total_pages} page(s). Call \`read_docx\` with \`mode='full'\` and \`page=N\` to read a section.
@@ -116,7 +138,12 @@ function build_outline_response(doc, projected_text, file_path, outline_max_leve
116
138
 
117
139
  ${ui_markdown}`;
118
140
  return {
119
- content: [{ type: "text", text: llm_content }]
141
+ content: [{ type: "text", text: llm_content }],
142
+ structuredContent: {
143
+ markdown: ui_markdown,
144
+ file_path: resolve(file_path),
145
+ title: `Outline: ${basename(file_path)}`
146
+ }
120
147
  };
121
148
  }
122
149
  function build_appendix_response(text, page, file_path) {
@@ -127,12 +154,19 @@ function build_appendix_response(text, page, file_path) {
127
154
 
128
155
  ${ui_markdown2}`;
129
156
  return {
130
- content: [{ type: "text", text: llm_content2 }]
157
+ content: [{ type: "text", text: llm_content2 }],
158
+ structuredContent: {
159
+ markdown: ui_markdown2,
160
+ file_path: resolve(file_path),
161
+ title: `Appendix: ${basename(file_path)}`
162
+ }
131
163
  };
132
164
  }
133
165
  const result = paginate(appendix, "");
134
166
  if (page < 1 || page > result.total_pages) {
135
- throw new Error(`Appendix page ${page} out of range (appendix has ${result.total_pages} pages).`);
167
+ throw new Error(
168
+ `Appendix page ${page} out of range (appendix has ${result.total_pages} pages).`
169
+ );
136
170
  }
137
171
  const selected = result.pages[page - 1];
138
172
  let banner = "";
@@ -156,7 +190,12 @@ ${ui_markdown2}`;
156
190
 
157
191
  ${ui_markdown}`;
158
192
  return {
159
- content: [{ type: "text", text: llm_content }]
193
+ content: [{ type: "text", text: llm_content }],
194
+ structuredContent: {
195
+ markdown: ui_markdown,
196
+ file_path: resolve(file_path),
197
+ title: `Appendix: ${basename(file_path)}`
198
+ }
160
199
  };
161
200
  }
162
201
 
@@ -177,6 +216,8 @@ import {
177
216
  // src/shared.ts
178
217
  var FRONTEND_URL = process.env.ADEU_FRONTEND_URL || "https://app.adeu.ai";
179
218
  var BACKEND_URL = process.env.ADEU_BACKEND_URL || "https://app.adeu.ai";
219
+ var MARKDOWN_UI_URI = "ui://adeu/markdown-ui";
220
+ var EMAIL_UI_URI = "ui://adeu/email-ui";
180
221
 
181
222
  // src/desktop-auth.ts
182
223
  var ADEU_DIR = join(homedir(), ".adeu");
@@ -279,7 +320,8 @@ async function login_to_adeu_cloud() {
279
320
  headers: {
280
321
  Authorization: `Bearer ${apiKey}`,
281
322
  Accept: "application/json"
282
- }
323
+ },
324
+ signal: AbortSignal.timeout(15e3)
283
325
  });
284
326
  if (res.status === 401) {
285
327
  DesktopAuthManager.clearApiKey();
@@ -318,8 +360,41 @@ import { homedir as homedir2, tmpdir } from "os";
318
360
  import { join as join2 } from "path";
319
361
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
320
362
  import { createHash } from "crypto";
363
+ var KNOWN_ERROR_HINTS = {
364
+ "Email not found.": "The email ID was not found. If this was a short ID (msg_*), it may have been evicted from the local cache or come from a different machine \u2014 re-run search_and_fetch_emails with filters to get a fresh ID. If it was an adeu_<numeric> or raw provider ID, verify it's correct.",
365
+ "Adeu email reference not found.": "The adeu_<id> reference doesn't resolve to any processed email for this user. Verify the ID, or re-run search_and_fetch_emails with filters to find the message.",
366
+ "Invalid adeu_ email ID format.": "The adeu_<id> reference is malformed. Expected format: adeu_<integer>."
367
+ };
368
+ function formatBackendError(statusCode, responseBody) {
369
+ let detail = responseBody;
370
+ try {
371
+ const parsed = JSON.parse(responseBody);
372
+ if (parsed && typeof parsed === "object" && "detail" in parsed) {
373
+ detail = String(parsed.detail);
374
+ }
375
+ } catch {
376
+ }
377
+ let hint = KNOWN_ERROR_HINTS[detail];
378
+ if (!hint && detail.startsWith("Mailbox '") && detail.endsWith("' not found.")) {
379
+ const mailbox = detail.slice("Mailbox '".length, -"' not found.".length);
380
+ hint = `The mailbox '${mailbox}' is not connected to your Adeu account. Call list_available_mailboxes to see valid mailbox addresses, then retry with one of those as \`mailbox_address\`.`;
381
+ }
382
+ const message = hint ?? detail;
383
+ return `Cloud search failed (HTTP ${statusCode}): ${message}`;
384
+ }
385
+ function isTimeoutError(err) {
386
+ if (!err || typeof err !== "object") return false;
387
+ const name = err.name;
388
+ return name === "TimeoutError" || name === "AbortError";
389
+ }
321
390
  var CACHE_FILE = join2(homedir2(), ".adeu", "mcp_id_cache.json");
322
391
  var MAX_CACHE_SIZE = 1e3;
392
+ function formatBytes(bytes) {
393
+ if (bytes == null) return "unknown size";
394
+ if (bytes < 1024) return `${bytes} B`;
395
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
396
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
397
+ }
323
398
  function loadIdCache() {
324
399
  if (existsSync2(CACHE_FILE)) {
325
400
  try {
@@ -350,32 +425,173 @@ function minifyEmailId(realId, cache) {
350
425
  cache[shortId] = realId;
351
426
  return shortId;
352
427
  }
428
+ var StaleShortIdError = class extends Error {
429
+ constructor(shortId) {
430
+ super(
431
+ `Short ID '${shortId}' is not in the local cache (it may have been evicted, or it came from a different machine/session). Short IDs only persist on the machine where they were generated. Re-run search_and_fetch_emails with filters (sender, subject, days_ago) to fetch fresh IDs, then use the new ID from those results.`
432
+ );
433
+ this.name = "StaleShortIdError";
434
+ }
435
+ };
353
436
  function resolveEmailId(shortId) {
354
437
  if (!shortId) return shortId;
438
+ if (shortId.startsWith("adeu_")) return shortId;
355
439
  const cache = loadIdCache();
356
- return cache[shortId] || shortId;
440
+ const resolved = cache[shortId];
441
+ if (resolved) return resolved;
442
+ if (shortId.startsWith("msg_")) {
443
+ throw new StaleShortIdError(shortId);
444
+ }
445
+ return shortId;
446
+ }
447
+ var HTML_NAMED_ENTITIES = {
448
+ nbsp: " ",
449
+ amp: "&",
450
+ lt: "<",
451
+ gt: ">",
452
+ quot: '"',
453
+ apos: "'",
454
+ copy: "\xA9",
455
+ reg: "\xAE",
456
+ trade: "\u2122",
457
+ hellip: "\u2026",
458
+ mdash: "\u2014",
459
+ ndash: "\u2013",
460
+ lsquo: "\u2018",
461
+ rsquo: "\u2019",
462
+ ldquo: "\u201C",
463
+ rdquo: "\u201D",
464
+ laquo: "\xAB",
465
+ raquo: "\xBB",
466
+ bull: "\u2022",
467
+ middot: "\xB7",
468
+ deg: "\xB0",
469
+ plusmn: "\xB1",
470
+ times: "\xD7",
471
+ divide: "\xF7",
472
+ euro: "\u20AC",
473
+ pound: "\xA3",
474
+ yen: "\xA5",
475
+ cent: "\xA2",
476
+ sect: "\xA7",
477
+ para: "\xB6",
478
+ iexcl: "\xA1",
479
+ iquest: "\xBF"
480
+ };
481
+ function decodeHtmlEntities(text) {
482
+ text = text.replace(/&#(\d+);/g, (_, dec) => {
483
+ const code = parseInt(dec, 10);
484
+ return Number.isFinite(code) ? String.fromCodePoint(code) : _;
485
+ });
486
+ text = text.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => {
487
+ const code = parseInt(hex, 16);
488
+ return Number.isFinite(code) ? String.fromCodePoint(code) : _;
489
+ });
490
+ text = text.replace(/&([a-zA-Z][a-zA-Z0-9]*);/g, (match, name) => {
491
+ const replacement = HTML_NAMED_ENTITIES[name.toLowerCase()];
492
+ return replacement !== void 0 ? replacement : match;
493
+ });
494
+ return text;
357
495
  }
358
496
  function stripTags(html) {
359
497
  if (!html) return "";
360
- let text = html.replace(/<(style|script|head)[^>]*>[\s\S]*?<\/\1>/gi, "");
498
+ let text = html;
499
+ const suppressPattern = /<(style|script|head|title)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
500
+ let prev;
501
+ do {
502
+ prev = text;
503
+ text = text.replace(suppressPattern, "");
504
+ } while (text !== prev);
505
+ text = text.replace(/<(style|script|head|title)\b[^>]*>[\s\S]*$/gi, "");
361
506
  text = text.replace(
362
507
  /<\/?(p|div|br|hr|tr|li|h[1-6]|blockquote)\b[^>]*>/gi,
363
508
  "\n"
364
509
  );
365
510
  text = text.replace(/<[^>]+>/g, "");
511
+ text = decodeHtmlEntities(text);
366
512
  return text.replace(/\n\s*\n\s*\n+/g, "\n\n").trim();
367
513
  }
368
514
  function removeNestedQuotes(text) {
369
515
  if (!text) return "";
370
- const patterns = [
516
+ const fromTokens = [
517
+ "From",
518
+ // English
519
+ "L\xE4hett\xE4j\xE4",
520
+ // Finnish
521
+ "Fr\xE5n",
522
+ // Swedish
523
+ "Von",
524
+ // German
525
+ "De",
526
+ // French / Spanish / Portuguese
527
+ "Da",
528
+ // Italian
529
+ "Van",
530
+ // Dutch
531
+ "Fra",
532
+ // Norwegian / Danish
533
+ "Mittente"
534
+ // Italian (alt)
535
+ ];
536
+ const sentTokens = [
537
+ "Sent",
538
+ "L\xE4hetetty",
539
+ "Skickat",
540
+ "Gesendet",
541
+ "Envoy\xE9",
542
+ "Enviado",
543
+ "Inviato",
544
+ "Verzonden",
545
+ "Sendt"
546
+ ];
547
+ const wrotePatterns = [
548
+ /On .{1,200}? wrote:/,
549
+ // English
550
+ /Le .{1,200}? a écrit\s*:/i,
551
+ // French
552
+ /Am .{1,200}? schrieb .{1,100}?:/i,
553
+ // German
554
+ /El .{1,200}? escribió\s*:/i,
555
+ // Spanish
556
+ /Il .{1,200}? ha scritto\s*:/i,
557
+ // Italian
558
+ /Op .{1,200}? schreef .{1,100}?:/i,
559
+ // Dutch
560
+ /Den .{1,200}? skrev .{1,100}?:/i,
561
+ // Swedish/Norwegian/Danish
562
+ /Em .{1,200}? escreveu\s*:/i,
563
+ // Portuguese
564
+ /Em\b.{1,200}?, .{1,200}? escreveu\s*:/i,
565
+ // Portuguese (date prefix)
566
+ new RegExp(
567
+ `^(${fromTokens.join("|")})\\s*:.*?\\n(?:.*\\n){0,5}?(${sentTokens.join("|")})\\s*:`,
568
+ "m"
569
+ )
570
+ ];
571
+ const forwardedTokens = [
572
+ "Forwarded message",
573
+ "V\xE4litetty viesti",
574
+ "Vidarebefordrat meddelande",
575
+ "Weitergeleitete Nachricht",
576
+ "Message transf\xE9r\xE9",
577
+ "Mensaje reenviado",
578
+ "Messaggio inoltrato",
579
+ "Doorgestuurd bericht",
580
+ "Videresendt melding",
581
+ "Videresendt meddelelse",
582
+ "Mensagem encaminhada"
583
+ ].join("|");
584
+ const dividerPatterns = [
371
585
  /_{10,}/m,
372
- /^From:\s.*?\n(?:.*\n){0,5}?Sent:\s/m,
373
- /-----Original Message-----/m,
374
- /On .{1,200}? wrote:/m,
375
- /^Original Message$/m
586
+ /-----\s*(Original Message|Alkuperäinen viesti|Ursprüngliches Nachricht|Message d'origine|Mensaje original|Messaggio originale|Oorspronkelijk bericht|Original meddelande)\s*-----/im,
587
+ /^(Original Message|Alkuperäinen viesti|Ursprüngliches Nachricht|Message d'origine|Mensaje original|Messaggio originale|Oorspronkelijk bericht)$/im,
588
+ // Gmail/Outlook-style "---------- Forwarded message ---------" with localized variants
589
+ new RegExp(`-+\\s*(${forwardedTokens})\\s*-+`, "i"),
590
+ new RegExp(`^(${forwardedTokens})$`, "im")
376
591
  ];
592
+ const allPatterns = [...wrotePatterns, ...dividerPatterns];
377
593
  let earliestCut = text.length;
378
- for (const pattern of patterns) {
594
+ for (const pattern of allPatterns) {
379
595
  const match = pattern.exec(text);
380
596
  if (match && match.index < earliestCut) {
381
597
  earliestCut = match.index;
@@ -384,20 +600,23 @@ function removeNestedQuotes(text) {
384
600
  return text.substring(0, earliestCut).trim();
385
601
  }
386
602
  function getUniqueFilepath(saveDir, filename) {
387
- let filepath = join2(saveDir, filename);
388
- let counter = 1;
389
- const parts = filename.split(".");
390
- const ext = parts.length > 1 ? `.${parts.pop()}` : "";
391
- const stem = parts.join(".");
392
- while (existsSync2(filepath)) {
393
- filepath = join2(saveDir, `${stem}_${counter}${ext}`);
394
- counter++;
395
- }
396
- return filepath;
603
+ return join2(saveDir, filename);
397
604
  }
398
605
  async function search_and_fetch_emails(args) {
399
606
  const apiKey = await getCloudAuthToken();
400
- const realEmailId = args.email_id ? resolveEmailId(args.email_id) : void 0;
607
+ const maxAttachmentSizeMb = typeof args.max_attachment_size_mb === "number" && args.max_attachment_size_mb > 0 ? args.max_attachment_size_mb : 10;
608
+ let realEmailId;
609
+ try {
610
+ realEmailId = args.email_id ? resolveEmailId(args.email_id) : void 0;
611
+ } catch (err) {
612
+ if (err instanceof StaleShortIdError) {
613
+ return {
614
+ isError: true,
615
+ content: [{ type: "text", text: err.message }]
616
+ };
617
+ }
618
+ throw err;
619
+ }
401
620
  const payload = {
402
621
  email_id: realEmailId,
403
622
  sender: args.sender,
@@ -408,26 +627,39 @@ async function search_and_fetch_emails(args) {
408
627
  days_ago: args.days_ago,
409
628
  folder: args.folder,
410
629
  limit: args.limit ?? 10,
411
- offset: args.offset ?? 0
630
+ offset: args.offset ?? 0,
631
+ mailbox_address: args.mailbox_address
412
632
  };
413
633
  Object.keys(payload).forEach(
414
634
  (k) => payload[k] === void 0 && delete payload[k]
415
635
  );
416
- const res = await fetch(`${BACKEND_URL}/api/v1/emails/search`, {
417
- method: "POST",
418
- headers: {
419
- Authorization: `Bearer ${apiKey}`,
420
- "Content-Type": "application/json"
421
- },
422
- body: JSON.stringify(payload)
423
- });
636
+ let res;
637
+ try {
638
+ res = await fetch(`${BACKEND_URL}/api/v1/emails/search`, {
639
+ method: "POST",
640
+ headers: {
641
+ Authorization: `Bearer ${apiKey}`,
642
+ "Content-Type": "application/json"
643
+ },
644
+ body: JSON.stringify(payload),
645
+ signal: AbortSignal.timeout(45e3)
646
+ });
647
+ } catch (err) {
648
+ if (isTimeoutError(err)) {
649
+ throw new Error(
650
+ "Email search timed out after 45s. The mail provider (Outlook/Gmail) may be slow. Try narrowing the search with more filters (sender, subject, days_ago), or retry shortly."
651
+ );
652
+ }
653
+ throw err;
654
+ }
424
655
  if (res.status === 401) {
425
656
  DesktopAuthManager.clearApiKey();
426
657
  throw new Error(
427
658
  "Authentication expired. Please call `login_to_adeu_cloud` to re-authenticate."
428
659
  );
429
660
  }
430
- if (!res.ok) throw new Error(`Cloud search failed: ${await res.text()}`);
661
+ if (!res.ok)
662
+ throw new Error(formatBackendError(res.status, await res.text()));
431
663
  const data = await res.json();
432
664
  const cache = loadIdCache();
433
665
  if (data.type === "previews") {
@@ -459,15 +691,23 @@ async function search_and_fetch_emails(args) {
459
691
  );
460
692
  }
461
693
  saveIdCache(cache);
694
+ const limit = typeof args.limit === "number" ? args.limit : 10;
695
+ const offset = typeof args.offset === "number" ? args.offset : 0;
696
+ const pageHint = previews.length >= limit ? `
697
+ *(If you need to see more results, call this tool again with offset=${offset + limit})*` : "";
462
698
  lines.push(
463
- "\u26A0\uFE0F **ACTION REQUIRED**: To read the full body of an email and download its attachments, call this tool again and provide the exact `email_id`."
699
+ "\u26A0\uFE0F **ACTION REQUIRED**: To read the full body of an email and download its attachments, call this tool again and provide the exact `email_id`." + pageHint
464
700
  );
465
- return { content: [{ type: "text", text: lines.join("\n") }] };
701
+ return {
702
+ content: [{ type: "text", text: lines.join("\n") }],
703
+ structuredContent: data
704
+ };
466
705
  }
467
706
  if (data.type === "full_email") {
468
707
  const full = data.full_email || {};
469
708
  const shortTargetId = minifyEmailId(full.id || "unknown_id", cache);
470
709
  saveIdCache(cache);
710
+ const autoEscalated = !args.email_id && (args.sender !== void 0 || args.subject !== void 0 || args.has_attachments !== void 0 || args.attachment_name !== void 0 || args.is_unread !== void 0 || args.days_ago !== void 0 || args.folder !== void 0);
471
711
  const baseDir = args.working_directory && existsSync2(args.working_directory) ? args.working_directory : tmpdir();
472
712
  const saveDir = join2(
473
713
  baseDir,
@@ -477,35 +717,67 @@ async function search_and_fetch_emails(args) {
477
717
  mkdirSync2(saveDir, { recursive: true });
478
718
  async function processAttachments(msg) {
479
719
  const localFiles = [];
720
+ const skipped = [];
721
+ const maxBytes = maxAttachmentSizeMb * 1024 * 1024;
480
722
  for (const att of msg.attachments || []) {
723
+ const filename = att.filename || "unnamed_file";
724
+ const size = typeof att.size_bytes === "number" ? att.size_bytes : null;
725
+ if (size != null && size > maxBytes) {
726
+ skipped.push({
727
+ filename,
728
+ size_bytes: size,
729
+ reason: `exceeds ${maxAttachmentSizeMb} MB cap`
730
+ });
731
+ delete att.base64_data;
732
+ continue;
733
+ }
481
734
  if (att.base64_data) {
482
735
  try {
483
- const filepath = getUniqueFilepath(
484
- saveDir,
485
- att.filename || "unnamed_file"
486
- );
736
+ const filepath = getUniqueFilepath(saveDir, filename);
487
737
  writeFileSync2(filepath, Buffer.from(att.base64_data, "base64"));
488
738
  localFiles.push(filepath);
739
+ att.local_path = filepath;
489
740
  delete att.base64_data;
490
741
  } catch (e) {
491
- console.error(`Failed to save attachment ${att.filename}`, e);
742
+ console.error(`Failed to save attachment ${filename}`, e);
743
+ skipped.push({
744
+ filename,
745
+ size_bytes: size,
746
+ reason: `download failed: ${e.message}`
747
+ });
492
748
  }
493
749
  }
494
750
  }
495
- return localFiles;
751
+ return { localFiles, skipped };
496
752
  }
497
- const targetFiles = await processAttachments(full);
498
- const lines = [
753
+ const { localFiles: targetFiles, skipped: targetSkipped } = await processAttachments(full);
754
+ const lines = [];
755
+ if (autoEscalated) {
756
+ lines.push(
757
+ "_(Search returned exactly one result; auto-fetched full email below.)_\n"
758
+ );
759
+ }
760
+ lines.push(
499
761
  `# Email Thread: ${full.subject}`,
500
762
  "",
501
763
  "## Target Message (Newest):",
502
764
  `**From**: ${full.sender_name} <${full.sender_email}>`,
503
765
  `**Date**: ${full.received_datetime}`
504
- ];
766
+ );
505
767
  if (targetFiles.length) {
506
768
  lines.push("**Attachments Saved Locally**:");
507
769
  targetFiles.forEach((f) => lines.push(`- \u{1F4CE} \`${f}\``));
508
770
  }
771
+ if (targetSkipped.length) {
772
+ lines.push(
773
+ `**Attachments Skipped (not downloaded)** \u2014 pass \`max_attachment_size_mb\` to raise the ${maxAttachmentSizeMb} MB cap:`
774
+ );
775
+ targetSkipped.forEach(
776
+ (s) => lines.push(
777
+ `- \u26A0\uFE0F \`${s.filename}\` (${formatBytes(s.size_bytes)}, ${s.reason})`
778
+ )
779
+ );
780
+ }
509
781
  const cleanBody = removeNestedQuotes(stripTags(full.body_html || ""));
510
782
  lines.push(`**Body**:
511
783
  \`\`\`
@@ -516,7 +788,7 @@ ${cleanBody}
516
788
  lines.push("## Previous Messages in Thread (Historical Context):");
517
789
  for (let i = 0; i < full.messages.length; i++) {
518
790
  const histMsg = full.messages[i];
519
- const histFiles = await processAttachments(histMsg);
791
+ const { localFiles: histFiles, skipped: histSkipped } = await processAttachments(histMsg);
520
792
  lines.push(
521
793
  `### Message -${i + 1} (Older)
522
794
  **From**: ${histMsg.sender_name} <${histMsg.sender_email}>
@@ -526,6 +798,16 @@ ${cleanBody}
526
798
  lines.push("**Attachments Saved Locally**:");
527
799
  histFiles.forEach((f) => lines.push(`- \u{1F4CE} \`${f}\``));
528
800
  }
801
+ if (histSkipped.length) {
802
+ lines.push(
803
+ `**Attachments Skipped (not downloaded)** \u2014 pass \`max_attachment_size_mb\` \u2014 raise the cap:`
804
+ );
805
+ histSkipped.forEach(
806
+ (s) => lines.push(
807
+ `- \u26A0\uFE0F \`${s.filename}\` (${formatBytes(s.size_bytes)}, ${s.reason})`
808
+ )
809
+ );
810
+ }
529
811
  lines.push(
530
812
  `**Body**:
531
813
  \`\`\`
@@ -535,7 +817,18 @@ ${removeNestedQuotes(stripTags(histMsg.body_html || ""))}
535
817
  );
536
818
  }
537
819
  }
538
- return { content: [{ type: "text", text: lines.join("\n") }] };
820
+ const hasAttachments = targetFiles.length > 0 || full.messages && full.messages.some(
821
+ (m) => m.attachments && m.attachments.length > 0
822
+ );
823
+ if (hasAttachments) {
824
+ lines.push(
825
+ "\n*You can now use tools like `read_docx`, `diff_docx_files`, or `finalize_document` on the local file paths listed under each message.*"
826
+ );
827
+ }
828
+ return {
829
+ content: [{ type: "text", text: lines.join("\n") }],
830
+ structuredContent: data
831
+ };
539
832
  }
540
833
  return {
541
834
  isError: true,
@@ -552,12 +845,25 @@ async function create_email_draft(args) {
552
845
  const formData = new FormData();
553
846
  formData.append("body_markdown", args.body_markdown);
554
847
  if (args.reply_to_email_id) {
555
- formData.append(
556
- "reply_to_email_id",
557
- resolveEmailId(args.reply_to_email_id)
558
- );
848
+ try {
849
+ formData.append(
850
+ "reply_to_email_id",
851
+ resolveEmailId(args.reply_to_email_id)
852
+ );
853
+ } catch (err) {
854
+ if (err instanceof StaleShortIdError) {
855
+ return {
856
+ isError: true,
857
+ content: [{ type: "text", text: err.message }]
858
+ };
859
+ }
860
+ throw err;
861
+ }
559
862
  }
560
863
  if (args.subject) formData.append("subject", args.subject);
864
+ if (args.mailbox_address) {
865
+ formData.append("mailbox_address", args.mailbox_address);
866
+ }
561
867
  if (args.to_recipients) {
562
868
  const recips = typeof args.to_recipients === "string" ? JSON.parse(args.to_recipients) : args.to_recipients;
563
869
  formData.append("to_recipients", JSON.stringify(recips));
@@ -570,11 +876,25 @@ async function create_email_draft(args) {
570
876
  formData.append("files", new Blob([buf]), filename);
571
877
  }
572
878
  }
573
- const res = await fetch(`${BACKEND_URL}/api/v1/emails/drafts/new`, {
574
- method: "POST",
575
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
576
- body: formData
577
- });
879
+ let res;
880
+ try {
881
+ res = await fetch(`${BACKEND_URL}/api/v1/emails/drafts/new`, {
882
+ method: "POST",
883
+ headers: {
884
+ Authorization: `Bearer ${apiKey}`,
885
+ Accept: "application/json"
886
+ },
887
+ body: formData,
888
+ signal: AbortSignal.timeout(9e4)
889
+ });
890
+ } catch (err) {
891
+ if (isTimeoutError(err)) {
892
+ throw new Error(
893
+ "Draft creation timed out after 90s. If the draft includes large attachments, try splitting them across multiple drafts or omitting the largest files."
894
+ );
895
+ }
896
+ throw err;
897
+ }
578
898
  if (res.status === 401) {
579
899
  DesktopAuthManager.clearApiKey();
580
900
  throw new Error(
@@ -582,7 +902,7 @@ async function create_email_draft(args) {
582
902
  );
583
903
  }
584
904
  if (!res.ok)
585
- throw new Error(`Cloud draft creation failed: ${await res.text()}`);
905
+ throw new Error(formatBackendError(res.status, await res.text()));
586
906
  const data = await res.json();
587
907
  return {
588
908
  content: [
@@ -593,6 +913,66 @@ async function create_email_draft(args) {
593
913
  ]
594
914
  };
595
915
  }
916
+ async function list_available_mailboxes() {
917
+ const apiKey = await getCloudAuthToken();
918
+ let res;
919
+ try {
920
+ res = await fetch(`${BACKEND_URL}/api/v1/users/me/shared-mailboxes`, {
921
+ method: "GET",
922
+ headers: {
923
+ Authorization: `Bearer ${apiKey}`,
924
+ Accept: "application/json"
925
+ },
926
+ signal: AbortSignal.timeout(15e3)
927
+ });
928
+ } catch (err) {
929
+ if (isTimeoutError(err)) {
930
+ throw new Error(
931
+ "Listing mailboxes timed out after 15s. The Adeu backend may be temporarily unavailable; retry shortly."
932
+ );
933
+ }
934
+ throw err;
935
+ }
936
+ if (res.status === 401) {
937
+ DesktopAuthManager.clearApiKey();
938
+ throw new Error(
939
+ "Authentication expired. Please call `login_to_adeu_cloud` to re-authenticate."
940
+ );
941
+ }
942
+ if (!res.ok) {
943
+ throw new Error(formatBackendError(res.status, await res.text()));
944
+ }
945
+ const mailboxes = await res.json();
946
+ if (!mailboxes.length) {
947
+ return {
948
+ content: [
949
+ {
950
+ type: "text",
951
+ text: "No configured mailboxes found for your profile."
952
+ }
953
+ ]
954
+ };
955
+ }
956
+ mailboxes.sort(
957
+ (a, b) => (a.email_address ?? "").toLowerCase().localeCompare((b.email_address ?? "").toLowerCase())
958
+ );
959
+ const lines = [
960
+ "### Connected Mailboxes",
961
+ "Below is the list of connected mailboxes you have access to. Use the `email_address` as the `mailbox_address` parameter in other tools to query or draft from a specific mailbox:",
962
+ ""
963
+ ];
964
+ for (const box of mailboxes) {
965
+ lines.push(
966
+ `- **${box.display_name || "Personal Mailbox"}**
967
+ - **Email Address**: \`${box.email_address}\`
968
+ - **Auto-Processing**: ${box.auto_process_enabled ? "Enabled" : "Disabled"}
969
+ - **Write-Back Mode**: \`${box.write_back_preference}\``
970
+ );
971
+ }
972
+ return {
973
+ content: [{ type: "text", text: lines.join("\n") }]
974
+ };
975
+ }
596
976
 
597
977
  // src/index.ts
598
978
  function readFileBytesOrThrow(filePath) {
@@ -605,416 +985,490 @@ function readFileBytesOrThrow(filePath) {
605
985
  throw err;
606
986
  }
607
987
  }
988
+ var DIST_DIR = import.meta.dirname;
989
+ function getAssetContent(folder, filename, fallbackMessage) {
990
+ const filePath = join3(DIST_DIR, folder, filename);
991
+ if (existsSync3(filePath)) {
992
+ return readFileSync3(filePath, "utf-8");
993
+ }
994
+ return fallbackMessage;
995
+ }
608
996
  var READ_DOCX_COMMON_DESC = "Reads a DOCX file. Returns text with inline CriticMarkup for Tracked Changes and Comments: {++inserted++}, {--deleted--}, {==highlighted==}{>>comment<<}. Set clean_view=True for the finalized 'Accepted' text without markup.\n\n";
609
997
  var READ_DOCX_TAIL = "Modes:\n- 'full' (default): paginated body content. Use page=N to navigate.\n- 'outline': heading map only \u2014 start here for large docs to plan targeted reads. Defaults to L1-L2 headings; pass outline_max_level=3-6 to see deeper structure.\n- 'appendix': defined terms, anchors, and cross-reference targets. Consult before editing legal/technical docs to avoid breaking references.";
610
998
  var PROCESS_BATCH_COMMON_DESC = "Applies a batch of edits and review actions to a DOCX.\n\nAll changes evaluate against the ORIGINAL document state \u2014 do not chain dependent edits within one batch (e.g. rename X to Y, then modify Y). Apply the rename first, then send a second batch.\n\n";
611
999
  var PROCESS_BATCH_OPERATIONS_DESC = "Each item in `changes` must specify a `type`:\n1. 'modify': Search-and-replace. `target_text` must uniquely match \u2014 include surrounding context if the phrase is ambiguous. `new_text` supports Markdown: '# Heading 1' through '###### Heading 6', '**bold**', '_italic_', and '\\n\\n' to split into multiple paragraphs. Empty `new_text` deletes. Do NOT write CriticMarkup tags ({++, {--, {>>) manually \u2014 use the `comment` parameter for comments.\n2. 'accept' / 'reject': Finalize or revert a tracked change by `target_id` (e.g. 'Chg:12').\n3. 'reply': Reply to a comment by `target_id` (e.g. 'Com:5') with `text`.\n4. 'insert_row' / 'delete_row': Table edits. Disk mode only \u2014 not supported on Live Word canvas.\n\nID VOLATILITY: 'Chg:N' and 'Com:N' shift between document states. Always call `read_docx` immediately before any accept/reject/reply \u2014 do not reuse IDs from earlier in the conversation.\n\n`author_name` is used for attribution on all tracked changes and comments, in both disk and Live Word modes.";
612
1000
  var DIFF_DOCX_DESC = "Compares two DOCX files and returns a unified diff of their text content. Useful for analyzing differences between versions before editing.";
613
- var server = new Server2(
1001
+ var server = new McpServer({
1002
+ name: "adeu-redlining-service",
1003
+ version: "1.0.0"
1004
+ });
1005
+ var UI_CSP = {
1006
+ connectDomains: ["https://fonts.googleapis.com", "https://fonts.gstatic.com"],
1007
+ resourceDomains: [
1008
+ "https://fonts.googleapis.com",
1009
+ "https://fonts.gstatic.com"
1010
+ ]
1011
+ };
1012
+ registerAppResource(
1013
+ server,
1014
+ MARKDOWN_UI_URI,
1015
+ MARKDOWN_UI_URI,
1016
+ { mimeType: RESOURCE_MIME_TYPE, description: "Adeu Markdown Viewer UI" },
1017
+ async () => {
1018
+ let html = getAssetContent(
1019
+ "templates",
1020
+ "markdown_ui.html",
1021
+ "<html><body>UI Template Not Found</body></html>"
1022
+ );
1023
+ const markedJs = getAssetContent(
1024
+ "assets",
1025
+ "marked.min.js",
1026
+ "window.__MARKED_ERROR = 'marked.min.js not found';"
1027
+ );
1028
+ const svg = getAssetContent("assets", "adeu.svg", "");
1029
+ html = html.replace("[[marked_js_code | safe]]", markedJs).replace("[[ adeu_svg_code ]]", svg);
1030
+ return {
1031
+ contents: [
1032
+ {
1033
+ uri: MARKDOWN_UI_URI,
1034
+ mimeType: RESOURCE_MIME_TYPE,
1035
+ text: html,
1036
+ _meta: { ui: { csp: UI_CSP } }
1037
+ }
1038
+ ]
1039
+ };
1040
+ }
1041
+ );
1042
+ registerAppResource(
1043
+ server,
1044
+ EMAIL_UI_URI,
1045
+ EMAIL_UI_URI,
1046
+ { mimeType: RESOURCE_MIME_TYPE, description: "Adeu Email Viewer UI" },
1047
+ async () => {
1048
+ let html = getAssetContent(
1049
+ "templates",
1050
+ "email_ui.html",
1051
+ "<html><body>UI Template Not Found</body></html>"
1052
+ );
1053
+ const svg = getAssetContent("assets", "adeu.svg", "");
1054
+ html = html.replace("[[ adeu_svg_code ]]", svg);
1055
+ return {
1056
+ contents: [
1057
+ {
1058
+ uri: EMAIL_UI_URI,
1059
+ mimeType: RESOURCE_MIME_TYPE,
1060
+ text: html,
1061
+ _meta: { ui: { csp: UI_CSP } }
1062
+ }
1063
+ ]
1064
+ };
1065
+ }
1066
+ );
1067
+ registerAppTool(
1068
+ server,
1069
+ "read_docx",
614
1070
  {
615
- name: "adeu-redlining-service",
616
- version: "1.0.0"
1071
+ title: "Read DOCX",
1072
+ description: READ_DOCX_COMMON_DESC + READ_DOCX_TAIL,
1073
+ inputSchema: z.object({
1074
+ file_path: z.string().describe("Absolute path to the DOCX file."),
1075
+ clean_view: z.boolean().default(false).describe(
1076
+ "If False (default), returns the 'Raw' text with inline CriticMarkup. If True, returns 'Accepted' text."
1077
+ ),
1078
+ mode: z.enum(["full", "outline", "appendix"]).default("full").describe(
1079
+ "'full' returns body content. 'outline' returns a structural heading map. 'appendix' returns defined terms."
1080
+ ),
1081
+ page: z.number().default(1).describe("Page number (1-indexed) for mode='full'. Defaults to 1."),
1082
+ outline_max_level: z.number().default(2).describe("For mode='outline' only: cap on heading depth."),
1083
+ outline_verbose: z.boolean().default(false).describe("For mode='outline' only: includes metadata.")
1084
+ }),
1085
+ _meta: { ui: { resourceUri: MARKDOWN_UI_URI } }
617
1086
  },
1087
+ async ({
1088
+ file_path,
1089
+ clean_view,
1090
+ mode,
1091
+ page,
1092
+ outline_max_level,
1093
+ outline_verbose
1094
+ }) => {
1095
+ try {
1096
+ const buf = readFileBytesOrThrow(file_path);
1097
+ const text = await extractTextFromBuffer(buf, clean_view);
1098
+ if (mode === "outline") {
1099
+ const doc = await DocumentObject2.load(buf);
1100
+ return build_outline_response(
1101
+ doc,
1102
+ text,
1103
+ file_path,
1104
+ outline_max_level,
1105
+ outline_verbose
1106
+ );
1107
+ }
1108
+ if (mode === "appendix") {
1109
+ return build_appendix_response(text, page, file_path);
1110
+ }
1111
+ return build_paginated_response(text, page, file_path);
1112
+ } catch (e) {
1113
+ return {
1114
+ isError: true,
1115
+ content: [
1116
+ {
1117
+ type: "text",
1118
+ text: `Error executing tool read_docx: ${e.message}`
1119
+ }
1120
+ ]
1121
+ };
1122
+ }
1123
+ }
1124
+ );
1125
+ registerAppTool(
1126
+ server,
1127
+ "search_and_fetch_emails",
618
1128
  {
619
- capabilities: {
620
- tools: {}
1129
+ title: "Search & Fetch Emails",
1130
+ description: "Searches the user's live email inbox via the Adeu cloud backend.\n\nTWO MODES:\n1. Search mode (no `email_id`): returns up to `limit` lightweight previews. Use filters (`sender`, `subject`, `is_unread`, `days_ago`, `folder`, `has_attachments`, `attachment_name`) to narrow down.\n2. Fetch mode (with `email_id`): returns the full email body, thread history, and downloads attachments under `max_attachment_size_mb` to the local disk.\n\nAUTO-ESCALATION: If a search returns exactly one preview, the backend automatically fetches the full email in the same call. Plan around the response shape \u2014 check the `type` field (`previews` vs `full_email`) before assuming.\n\nEMAIL ID FORMATS (`email_id` parameter accepts any of):\n- `msg_<6 chars>` \u2014 short ID returned by previews on THIS machine. NOT portable across machines or sessions; the local cache holds the most recent 1000. If you reference one that's been evicted, the tool returns a StaleShortIdError telling you to re-search.\n- `adeu_<numeric>` \u2014 server-side reference for emails Adeu has previously processed. Portable across machines and sessions for the same authenticated user.\n- Raw provider ID (Gmail/Outlook native ID) \u2014 works if you have it, but you usually won't.\n\nFOLDER DEFAULT: omitting `folder` searches the Inbox only (matching what the user sees in their mail client). Use `folder='sent'` for sent items, `folder='all'` to include Deleted Items, Drafts, and other folders.\n\nATTACHMENTS: attachments larger than `max_attachment_size_mb` (default 10) are listed in the response but NOT downloaded \u2014 raise the cap if you need them. Always set `working_directory` when calling from a project so attachments land alongside the user's other files.",
1131
+ inputSchema: z.object({
1132
+ sender: z.string().optional(),
1133
+ subject: z.string().optional(),
1134
+ has_attachments: z.boolean().optional(),
1135
+ attachment_name: z.string().optional(),
1136
+ is_unread: z.boolean().optional(),
1137
+ days_ago: z.number().optional(),
1138
+ folder: z.enum(["inbox", "sent", "all"]).optional(),
1139
+ limit: z.number().default(10),
1140
+ offset: z.number().default(0),
1141
+ email_id: z.string().optional(),
1142
+ working_directory: z.string().optional(),
1143
+ mailbox_address: z.string().optional().describe("Optional target mailbox email address to search within."),
1144
+ max_attachment_size_mb: z.number().optional().describe(
1145
+ "Maximum attachment size in MB to download (default 10). Attachments larger than this are listed in the response but not downloaded. Raise this to fetch large files."
1146
+ )
1147
+ }),
1148
+ _meta: { ui: { resourceUri: EMAIL_UI_URI } }
1149
+ },
1150
+ async (args) => {
1151
+ try {
1152
+ return await search_and_fetch_emails(args);
1153
+ } catch (e) {
1154
+ return {
1155
+ isError: true,
1156
+ content: [{ type: "text", text: e.message }]
1157
+ };
621
1158
  }
622
1159
  }
623
1160
  );
624
- server.setRequestHandler(ListToolsRequestSchema, async () => {
625
- return {
626
- tools: [
627
- {
628
- name: "read_docx",
629
- description: READ_DOCX_COMMON_DESC + READ_DOCX_TAIL,
630
- inputSchema: {
631
- type: "object",
632
- properties: {
633
- file_path: {
634
- type: "string",
635
- description: "Absolute path to the DOCX file."
636
- },
637
- clean_view: {
638
- type: "boolean",
639
- description: "If False (default), returns the 'Raw' text with inline CriticMarkup. If True, returns 'Accepted' text.",
640
- default: false
641
- },
642
- mode: {
643
- type: "string",
644
- enum: ["full", "outline", "appendix"],
645
- description: "'full' returns body content. 'outline' returns a structural heading map. 'appendix' returns defined terms.",
646
- default: "full"
647
- },
648
- page: {
649
- type: "number",
650
- description: "Page number (1-indexed) for mode='full'. Defaults to 1.",
651
- default: 1
652
- },
653
- outline_max_level: {
654
- type: "number",
655
- description: "For mode='outline' only: cap on heading depth.",
656
- default: 2
657
- },
658
- outline_verbose: {
659
- type: "boolean",
660
- description: "For mode='outline' only: includes metadata.",
661
- default: false
662
- }
663
- },
664
- required: ["file_path"]
665
- }
666
- },
667
- {
668
- name: "process_document_batch",
669
- description: PROCESS_BATCH_COMMON_DESC + PROCESS_BATCH_OPERATIONS_DESC,
670
- inputSchema: {
671
- type: "object",
672
- properties: {
673
- original_docx_path: {
674
- type: "string",
675
- description: "Absolute path to the source file."
676
- },
677
- author_name: {
678
- type: "string",
679
- description: "Name to appear in Track Changes (e.g., 'Reviewer AI')."
680
- },
681
- changes: {
682
- type: "array",
683
- description: "List of changes to apply. Each change must specify 'type'.",
684
- items: { type: "object" }
685
- },
686
- output_path: {
687
- type: "string",
688
- description: "Optional output path."
689
- }
690
- },
691
- required: ["original_docx_path", "author_name", "changes"]
692
- }
693
- },
694
- {
695
- name: "accept_all_changes",
696
- description: "Accepts all tracked changes and removes all comments in a single operation, producing a finalized clean document. Use this when a document review is entirely complete and you want to clear all redlines.",
697
- inputSchema: {
698
- type: "object",
699
- properties: {
700
- docx_path: {
701
- type: "string",
702
- description: "Absolute path to the DOCX file."
703
- },
704
- output_path: {
705
- type: "string",
706
- description: "Optional output path."
707
- }
708
- },
709
- required: ["docx_path"]
710
- }
711
- },
712
- {
713
- name: "diff_docx_files",
714
- description: DIFF_DOCX_DESC,
715
- inputSchema: {
716
- type: "object",
717
- properties: {
718
- original_path: {
719
- type: "string",
720
- description: "Absolute path to the baseline DOCX file."
721
- },
722
- modified_path: {
723
- type: "string",
724
- description: "Absolute path to the modified DOCX file."
725
- },
726
- compare_clean: {
727
- type: "boolean",
728
- description: "If True, compares 'Accepted' state. If False, compares raw text.",
729
- default: true
730
- }
731
- },
732
- required: ["original_path", "modified_path"]
733
- }
734
- },
735
- {
736
- name: "finalize_document",
737
- description: "Prepares a document for external distribution or e-signature. This tool combines metadata sanitization, document locking (protection), and markup resolution into a single step. NOTE: PDF export and AES encryption are disabled in this environment.",
738
- inputSchema: {
739
- type: "object",
740
- properties: {
741
- file_path: {
742
- type: "string",
743
- description: "Absolute path to the DOCX file."
744
- },
745
- output_path: {
746
- type: "string",
747
- description: "Optional output path."
748
- },
749
- sanitize_mode: {
750
- type: "string",
751
- enum: ["full", "keep-markup"],
752
- description: "full removes all markup, keep-markup redacts metadata but keeps comments/redlines."
753
- },
754
- accept_all: {
755
- type: "boolean",
756
- description: "If true, auto-accepts all unresolved track changes before finalizing."
757
- },
758
- protection_mode: {
759
- type: "string",
760
- enum: ["read_only", "encrypt"],
761
- description: "Native OOXML document locking. encrypt falls back to read_only in this environment."
762
- },
763
- password: {
764
- type: "string",
765
- description: "Ignored in this environment."
766
- },
767
- author: {
768
- type: "string",
769
- description: "Replace all remaining markup authorship with this name."
770
- },
771
- export_pdf: {
772
- type: "boolean",
773
- description: "Ignored in this environment."
774
- }
775
- },
776
- required: ["file_path"]
777
- }
778
- },
779
- {
780
- name: "login_to_adeu_cloud",
781
- description: "Logs the user into the Adeu Cloud backend. Securely opens a browser window for authentication.",
782
- inputSchema: { type: "object", properties: {} }
783
- },
784
- {
785
- name: "logout_of_adeu_cloud",
786
- description: "Logs out of the Adeu Cloud backend by clearing the local API key.",
787
- inputSchema: { type: "object", properties: {} }
788
- },
789
- {
790
- name: "search_and_fetch_emails",
791
- description: "Searches the user's live email inbox. By default, searches only the Inbox folder. Returns a list of lightweight previews. Call again with `email_id` to fetch the full body and download attachments.",
792
- inputSchema: {
793
- type: "object",
794
- properties: {
795
- sender: { type: "string" },
796
- subject: { type: "string" },
797
- has_attachments: { type: "boolean" },
798
- attachment_name: { type: "string" },
799
- is_unread: { type: "boolean" },
800
- days_ago: { type: "number" },
801
- folder: { type: "string", enum: ["inbox", "sent", "all"] },
802
- limit: { type: "number", default: 10 },
803
- offset: { type: "number", default: 0 },
804
- email_id: { type: "string" },
805
- working_directory: { type: "string" }
806
- }
807
- }
808
- },
809
- {
810
- name: "create_email_draft",
811
- description: "Creates an email draft in the user's native draft box. Provide `reply_to_email_id` to reply, or `subject` and `to_recipients` for a new email.",
812
- inputSchema: {
813
- type: "object",
814
- properties: {
815
- body_markdown: { type: "string" },
816
- reply_to_email_id: { type: "string" },
817
- subject: { type: "string" },
818
- to_recipients: { type: "array", items: { type: "string" } },
819
- attachment_paths: { type: "array", items: { type: "string" } }
820
- },
821
- required: ["body_markdown"]
822
- }
823
- }
824
- ]
825
- };
826
- });
827
- server.setRequestHandler(
828
- CallToolRequestSchema,
829
- async (request) => {
830
- const { name, arguments: args } = request.params;
1161
+ server.registerTool(
1162
+ "process_document_batch",
1163
+ {
1164
+ description: PROCESS_BATCH_COMMON_DESC + PROCESS_BATCH_OPERATIONS_DESC,
1165
+ inputSchema: {
1166
+ original_docx_path: z.string().describe("Absolute path to the source file."),
1167
+ author_name: z.string().describe("Name to appear in Track Changes (e.g., 'Reviewer AI')."),
1168
+ changes: z.array(z.any()).describe("List of changes to apply. Each change must specify 'type'."),
1169
+ output_path: z.string().optional().describe("Optional output path."),
1170
+ dry_run: z.boolean().optional().default(false).describe("If True, simulates the changes and returns a detailed preview report without modifying any files.")
1171
+ }
1172
+ },
1173
+ async ({ original_docx_path, author_name, changes, output_path, dry_run }) => {
831
1174
  try {
832
- if (name === "read_docx") {
833
- const filePath = args?.file_path;
834
- const cleanView = args?.clean_view ?? false;
835
- const mode = args?.mode ?? "full";
836
- const page = args?.page ?? 1;
837
- const outline_max_level = args?.outline_max_level ?? 2;
838
- const outline_verbose = args?.outline_verbose ?? false;
839
- const buf = readFileBytesOrThrow(filePath);
840
- const text = await extractTextFromBuffer(buf, cleanView);
841
- if (mode === "outline") {
842
- const doc = await DocumentObject2.load(buf);
843
- return build_outline_response(
844
- doc,
845
- text,
846
- filePath,
847
- outline_max_level,
848
- outline_verbose
849
- );
850
- }
851
- if (mode === "appendix") {
852
- return build_appendix_response(text, page, filePath);
853
- }
854
- return build_paginated_response(text, page, filePath);
855
- }
856
- if (name === "process_document_batch") {
857
- const origPath = args?.original_docx_path;
858
- const authorName = args?.author_name;
859
- const changes = args?.changes;
860
- let outPath = args?.output_path;
861
- if (!authorName || !authorName.trim()) {
862
- return {
863
- content: [
864
- { type: "text", text: "Error: author_name cannot be empty." }
865
- ]
866
- };
867
- }
868
- if (!changes || changes.length === 0) {
869
- return {
870
- content: [{ type: "text", text: "Error: No changes provided." }]
871
- };
872
- }
873
- if (!outPath) {
874
- const ext = extname(origPath);
875
- const base = basename2(origPath, ext);
876
- const dir = dirname(origPath);
877
- outPath = resolve2(dir, `${base}_processed${ext}`);
878
- }
879
- const buf = readFileBytesOrThrow(origPath);
880
- const doc = await DocumentObject2.load(buf);
881
- const engine = new RedlineEngine(doc, authorName);
882
- let stats;
883
- try {
884
- stats = engine.process_batch(changes);
885
- } catch (e) {
886
- if (e instanceof BatchValidationError) {
887
- return {
888
- content: [
889
- {
890
- type: "text",
891
- text: `Batch rejected. Some edits failed validation:
892
-
893
- ${e.errors.join("\n\n")}`
894
- }
895
- ],
896
- isError: true
897
- };
898
- }
899
- throw e;
900
- }
901
- const outBuf = await doc.save();
902
- const fs = await import("fs");
903
- fs.writeFileSync(outPath, outBuf);
904
- let res = `Batch complete. Saved to: ${outPath}
905
- Actions: ${stats.actions_applied} applied, ${stats.actions_skipped} skipped.
906
- Edits: ${stats.edits_applied} applied, ${stats.edits_skipped} skipped.`;
907
- if (stats.skipped_details?.length > 0) {
908
- res += `
909
-
910
- Skipped Details:
911
- ${stats.skipped_details.join("\n")}`;
912
- }
913
- return {
914
- content: [{ type: "text", text: res }]
915
- };
916
- }
917
- if (name === "accept_all_changes") {
918
- const docxPath = args?.docx_path;
919
- let outPath = args?.output_path;
920
- if (!outPath) {
921
- const ext = extname(docxPath);
922
- const base = basename2(docxPath, ext);
923
- const dir = dirname(docxPath);
924
- outPath = resolve2(dir, `${base}_clean${ext}`);
925
- }
926
- const buf = readFileBytesOrThrow(docxPath);
927
- const doc = await DocumentObject2.load(buf);
928
- const engine = new RedlineEngine(doc);
929
- engine.accept_all_revisions();
930
- const outBuf = await doc.save();
931
- const fs = await import("fs");
932
- fs.writeFileSync(outPath, outBuf);
1175
+ if (!author_name || !author_name.trim())
933
1176
  return {
934
1177
  content: [
935
- {
936
- type: "text",
937
- text: `Accepted all changes. Saved to: ${outPath}`
938
- }
1178
+ { type: "text", text: "Error: author_name cannot be empty." }
939
1179
  ]
940
1180
  };
941
- }
942
- if (name === "diff_docx_files") {
943
- const origPath = args?.original_path;
944
- const modPath = args?.modified_path;
945
- const compareClean = args?.compare_clean ?? true;
946
- const origBuf = readFileBytesOrThrow(origPath);
947
- const modBuf = readFileBytesOrThrow(modPath);
948
- const origText = await extractTextFromBuffer(origBuf, compareClean);
949
- const modText = await extractTextFromBuffer(modBuf, compareClean);
950
- const diff = create_word_patch_diff(
951
- origText,
952
- modText,
953
- basename2(origPath),
954
- basename2(modPath)
955
- );
1181
+ if (!changes || changes.length === 0)
956
1182
  return {
957
- content: [{ type: "text", text: diff || "No differences found." }]
1183
+ content: [{ type: "text", text: "Error: No changes provided." }]
958
1184
  };
1185
+ let outPath = output_path;
1186
+ if (!outPath) {
1187
+ const ext = extname(original_docx_path);
1188
+ const base = basename2(original_docx_path, ext);
1189
+ const dir = dirname(original_docx_path);
1190
+ outPath = resolve2(dir, `${base}_processed${ext}`);
959
1191
  }
960
- if (name === "finalize_document") {
961
- const filePath = args?.file_path;
962
- let outPath = args?.output_path;
963
- if (!outPath) {
964
- const ext = extname(filePath);
965
- const base = basename2(filePath, ext);
966
- const dir = dirname(filePath);
967
- outPath = resolve2(dir, `${base}_final${ext}`);
968
- }
969
- const buf = readFileBytesOrThrow(filePath);
970
- const doc = await DocumentObject2.load(buf);
971
- const result = await finalize_document(doc, {
972
- filename: basename2(filePath),
973
- sanitize_mode: args?.sanitize_mode || "full",
974
- accept_all: args?.accept_all,
975
- protection_mode: args?.protection_mode,
976
- author: args?.author,
977
- export_pdf: args?.export_pdf
978
- });
979
- const fs = await import("fs");
980
- fs.writeFileSync(outPath, result.outBuffer);
981
- return {
982
- content: [
983
- {
984
- type: "text",
985
- text: `Saved to: ${outPath}
1192
+ const buf = readFileBytesOrThrow(original_docx_path);
1193
+ const doc = await DocumentObject2.load(buf);
1194
+ const engine = new RedlineEngine(doc, author_name);
1195
+ let stats;
1196
+ try {
1197
+ stats = engine.process_batch(changes, dry_run);
1198
+ } catch (e) {
1199
+ if (e instanceof BatchValidationError) {
1200
+ return {
1201
+ isError: true,
1202
+ content: [
1203
+ {
1204
+ type: "text",
1205
+ text: `Batch rejected. Some edits failed validation:
986
1206
 
987
- ${result.reportText}`
988
- }
989
- ]
990
- };
991
- }
992
- if (name === "login_to_adeu_cloud") {
993
- return await login_to_adeu_cloud();
1207
+ ${e.errors.join("\n\n")}`
1208
+ }
1209
+ ]
1210
+ };
1211
+ }
1212
+ throw e;
994
1213
  }
995
- if (name === "logout_of_adeu_cloud") {
996
- return await logout_of_adeu_cloud();
1214
+ if (!dry_run) {
1215
+ const outBuf = await doc.save();
1216
+ fs.writeFileSync(outPath, outBuf);
997
1217
  }
998
- if (name === "search_and_fetch_emails") {
999
- return await search_and_fetch_emails(args || {});
1218
+ const res = formatBatchResult(stats, outPath, !!dry_run);
1219
+ return { content: [{ type: "text", text: res }] };
1220
+ } catch (e) {
1221
+ return {
1222
+ isError: true,
1223
+ content: [{ type: "text", text: `Error: ${e.message}` }]
1224
+ };
1225
+ }
1226
+ }
1227
+ );
1228
+ server.registerTool(
1229
+ "accept_all_changes",
1230
+ {
1231
+ description: "Accepts all tracked changes and removes all comments in a single operation.",
1232
+ inputSchema: {
1233
+ docx_path: z.string().describe("Absolute path to the DOCX file."),
1234
+ output_path: z.string().optional().describe("Optional output path.")
1235
+ }
1236
+ },
1237
+ async ({ docx_path, output_path }) => {
1238
+ try {
1239
+ let outPath = output_path;
1240
+ if (!outPath) {
1241
+ const ext = extname(docx_path);
1242
+ const base = basename2(docx_path, ext);
1243
+ const dir = dirname(docx_path);
1244
+ outPath = resolve2(dir, `${base}_clean${ext}`);
1000
1245
  }
1001
- if (name === "create_email_draft") {
1002
- return await create_email_draft(args || {});
1246
+ const buf = readFileBytesOrThrow(docx_path);
1247
+ const doc = await DocumentObject2.load(buf);
1248
+ const engine = new RedlineEngine(doc);
1249
+ engine.accept_all_revisions();
1250
+ const outBuf = await doc.save();
1251
+ fs.writeFileSync(outPath, outBuf);
1252
+ return {
1253
+ content: [
1254
+ { type: "text", text: `Accepted all changes. Saved to: ${outPath}` }
1255
+ ]
1256
+ };
1257
+ } catch (e) {
1258
+ return {
1259
+ isError: true,
1260
+ content: [{ type: "text", text: `Error: ${e.message}` }]
1261
+ };
1262
+ }
1263
+ }
1264
+ );
1265
+ server.registerTool(
1266
+ "diff_docx_files",
1267
+ {
1268
+ description: DIFF_DOCX_DESC,
1269
+ inputSchema: {
1270
+ original_path: z.string().describe("Absolute path to the baseline DOCX file."),
1271
+ modified_path: z.string().describe("Absolute path to the modified DOCX file."),
1272
+ compare_clean: z.boolean().default(true).describe(
1273
+ "If True, compares 'Accepted' state. If False, compares raw text."
1274
+ )
1275
+ }
1276
+ },
1277
+ async ({ original_path, modified_path, compare_clean }) => {
1278
+ try {
1279
+ const origBuf = readFileBytesOrThrow(original_path);
1280
+ const modBuf = readFileBytesOrThrow(modified_path);
1281
+ const origText = await extractTextFromBuffer(origBuf, compare_clean);
1282
+ const modText = await extractTextFromBuffer(modBuf, compare_clean);
1283
+ const diff = create_word_patch_diff(
1284
+ origText,
1285
+ modText,
1286
+ basename2(original_path),
1287
+ basename2(modified_path)
1288
+ );
1289
+ return {
1290
+ content: [{ type: "text", text: diff || "No differences found." }]
1291
+ };
1292
+ } catch (e) {
1293
+ return {
1294
+ isError: true,
1295
+ content: [{ type: "text", text: `Error: ${e.message}` }]
1296
+ };
1297
+ }
1298
+ }
1299
+ );
1300
+ server.registerTool(
1301
+ "finalize_document",
1302
+ {
1303
+ description: "Prepares a document for external distribution or e-signature.",
1304
+ inputSchema: {
1305
+ file_path: z.string().describe("Absolute path to the DOCX file."),
1306
+ output_path: z.string().optional().describe("Optional output path."),
1307
+ sanitize_mode: z.enum(["full", "keep-markup"]).optional().describe("full removes all markup, keep-markup redacts metadata."),
1308
+ accept_all: z.boolean().optional().describe(
1309
+ "If true, auto-accepts all unresolved track changes before finalizing."
1310
+ ),
1311
+ protection_mode: z.enum(["read_only", "encrypt"]).optional().describe("Native OOXML document locking."),
1312
+ password: z.string().optional().describe("Ignored in this environment."),
1313
+ author: z.string().optional().describe("Replace all remaining markup authorship with this name."),
1314
+ export_pdf: z.boolean().optional().describe("Ignored in this environment.")
1315
+ }
1316
+ },
1317
+ async ({
1318
+ file_path,
1319
+ output_path,
1320
+ sanitize_mode,
1321
+ accept_all,
1322
+ protection_mode,
1323
+ author,
1324
+ export_pdf
1325
+ }) => {
1326
+ try {
1327
+ let outPath = output_path;
1328
+ if (!outPath) {
1329
+ const ext = extname(file_path);
1330
+ const base = basename2(file_path, ext);
1331
+ const dir = dirname(file_path);
1332
+ outPath = resolve2(dir, `${base}_final${ext}`);
1003
1333
  }
1004
- throw new Error(`Unknown tool: ${name}`);
1005
- } catch (error) {
1334
+ const buf = readFileBytesOrThrow(file_path);
1335
+ const doc = await DocumentObject2.load(buf);
1336
+ const result = await finalize_document(doc, {
1337
+ filename: basename2(file_path),
1338
+ sanitize_mode: sanitize_mode || "full",
1339
+ accept_all,
1340
+ protection_mode,
1341
+ author,
1342
+ export_pdf
1343
+ });
1344
+ fs.writeFileSync(outPath, result.outBuffer);
1006
1345
  return {
1007
1346
  content: [
1008
1347
  {
1009
1348
  type: "text",
1010
- text: `Error executing tool ${name}: ${error.message}`
1349
+ text: `Saved to: ${outPath}
1350
+
1351
+ ${result.reportText}`
1011
1352
  }
1012
- ],
1013
- isError: true
1353
+ ]
1354
+ };
1355
+ } catch (e) {
1356
+ return {
1357
+ isError: true,
1358
+ content: [{ type: "text", text: `Error: ${e.message}` }]
1014
1359
  };
1015
1360
  }
1016
1361
  }
1017
1362
  );
1363
+ server.registerTool(
1364
+ "login_to_adeu_cloud",
1365
+ { description: "Logs the user into the Adeu Cloud backend." },
1366
+ async () => {
1367
+ try {
1368
+ return await login_to_adeu_cloud();
1369
+ } catch (e) {
1370
+ return { isError: true, content: [{ type: "text", text: e.message }] };
1371
+ }
1372
+ }
1373
+ );
1374
+ server.registerTool(
1375
+ "logout_of_adeu_cloud",
1376
+ { description: "Logs out of the Adeu Cloud backend." },
1377
+ async () => {
1378
+ try {
1379
+ return await logout_of_adeu_cloud();
1380
+ } catch (e) {
1381
+ return { isError: true, content: [{ type: "text", text: e.message }] };
1382
+ }
1383
+ }
1384
+ );
1385
+ server.registerTool(
1386
+ "create_email_draft",
1387
+ {
1388
+ description: "Creates an email draft in the user's native draft box (Outlook Drafts or Gmail Drafts).\n\nTWO MODES:\n1. Reply mode: pass `reply_to_email_id` to create a threaded reply. The draft inherits subject, recipients, and threading headers from the original \u2014 do NOT pass `subject` or `to_recipients`.\n2. New email mode: omit `reply_to_email_id` and pass BOTH `subject` and `to_recipients`.\n\n`reply_to_email_id` accepts the same ID formats as search_and_fetch_emails (`msg_*` short IDs, `adeu_*` references, or raw provider IDs). Short IDs are validated against the local cache before the call; stale ones fail fast with a clear error telling you to re-search.\n\n`body_markdown` is converted server-side to styled HTML with inlined CSS for email-client compatibility. Write the body in plain Markdown \u2014 do not pre-render HTML.\n\n`attachment_paths` takes absolute file paths on the user's local disk and uploads them with the draft. Useful right after search_and_fetch_emails downloaded attachments \u2014 those local paths can be passed directly here.",
1389
+ inputSchema: {
1390
+ body_markdown: z.string(),
1391
+ reply_to_email_id: z.string().optional(),
1392
+ subject: z.string().optional(),
1393
+ to_recipients: z.array(z.string()).optional(),
1394
+ attachment_paths: z.array(z.string()).optional(),
1395
+ mailbox_address: z.string().optional().describe(
1396
+ "Optional target mailbox email address to create the draft in."
1397
+ )
1398
+ }
1399
+ },
1400
+ async (args) => {
1401
+ try {
1402
+ return await create_email_draft(args);
1403
+ } catch (e) {
1404
+ return { isError: true, content: [{ type: "text", text: e.message }] };
1405
+ }
1406
+ }
1407
+ );
1408
+ server.registerTool(
1409
+ "list_available_mailboxes",
1410
+ {
1411
+ description: "Lists all personal and shared delegated mailboxes the authenticated user has access to. Returns each mailbox's `email_address`, `display_name`, auto-processing settings, and write-back preference.\n\nCall this FIRST when the user mentions a specific mailbox or shared inbox by name, to resolve the canonical `email_address`. Then pass that address as `mailbox_address` to `search_and_fetch_emails` or `create_email_draft` to scope the operation.\n\nOmitting `mailbox_address` on those tools targets the user's primary personal mailbox.",
1412
+ inputSchema: {}
1413
+ },
1414
+ async () => {
1415
+ try {
1416
+ return await list_available_mailboxes();
1417
+ } catch (e) {
1418
+ return { isError: true, content: [{ type: "text", text: e.message }] };
1419
+ }
1420
+ }
1421
+ );
1422
+ function formatBatchResult(stats, outPath, dry_run) {
1423
+ let res = "";
1424
+ if (dry_run) {
1425
+ res = `Dry-run simulation complete.
1426
+ `;
1427
+ } else {
1428
+ res = `Batch complete. Saved to: ${outPath}
1429
+ `;
1430
+ }
1431
+ res += `Actions: ${stats.actions_applied} applied, ${stats.actions_skipped} skipped.
1432
+ `;
1433
+ res += `Edits: ${stats.edits_applied} applied, ${stats.edits_skipped} skipped.
1434
+ `;
1435
+ if (stats.edits && stats.edits.length > 0) {
1436
+ res += "\nDetailed Edit Reports:\n";
1437
+ for (let i = 0; i < stats.edits.length; i++) {
1438
+ const report = stats.edits[i];
1439
+ const status_indicator = report.status === "applied" ? "\u2705 [applied]" : "\u274C [failed]";
1440
+ res += `Edit ${i + 1} ${status_indicator}:
1441
+ `;
1442
+ res += ` Target: '${report.target_text}'
1443
+ `;
1444
+ res += ` New text: '${report.new_text}'
1445
+ `;
1446
+ if (report.warning) {
1447
+ res += ` Warning: ${report.warning}
1448
+ `;
1449
+ }
1450
+ if (report.error) {
1451
+ res += ` Error: ${report.error}
1452
+ `;
1453
+ }
1454
+ if (report.critic_markup) {
1455
+ res += ` Preview (CriticMarkup): ${report.critic_markup}
1456
+ `;
1457
+ }
1458
+ if (report.clean_text) {
1459
+ res += ` Clean text preview: ${report.clean_text}
1460
+ `;
1461
+ }
1462
+ }
1463
+ }
1464
+ if (stats.skipped_details && stats.skipped_details.length > 0) {
1465
+ res += `
1466
+
1467
+ Skipped Details:
1468
+ ${stats.skipped_details.join("\n")}`;
1469
+ }
1470
+ return res;
1471
+ }
1018
1472
  async function main() {
1019
1473
  const transport = new StdioServerTransport();
1020
1474
  await server.connect(transport);
@@ -1023,4 +1477,7 @@ async function main() {
1023
1477
  );
1024
1478
  }
1025
1479
  main().catch(console.error);
1480
+ export {
1481
+ formatBatchResult
1482
+ };
1026
1483
  //# sourceMappingURL=index.js.map