@adeu/mcp-server 1.15.1 → 1.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/mcp-server",
3
- "version": "1.15.1",
3
+ "version": "1.16.0",
4
4
  "description": "",
5
5
  "mcpName": "ai.adeu/adeu",
6
6
  "main": "./dist/index.js",
@@ -31,7 +31,7 @@
31
31
  "license": "MIT",
32
32
  "type": "module",
33
33
  "dependencies": {
34
- "@adeu/core": "^1.15.1",
34
+ "@adeu/core": "^1.16.0",
35
35
  "@modelcontextprotocol/sdk": "^1.0.0",
36
36
  "@modelcontextprotocol/ext-apps": "^1.0.0",
37
37
  "zod": "^3.23.8"
package/src/index.ts CHANGED
@@ -42,17 +42,17 @@ function readFileBytesOrThrow(filePath: string): Buffer {
42
42
  if (err.code === "ENOENT") {
43
43
  throw new Error(
44
44
  `File not found: ${filePath}.\n` +
45
- `If you are running in a sandboxed/containerized environment (such as Claude Desktop or another containerized client), ` +
46
- `the host application or MCP server may not have direct access to your local workspace files.\n` +
47
- `You can resolve this by installing and running the local 'adeu' CLI tool directly within your environment.\n` +
48
- `Here is how the MCP tools map to their CLI equivalents:\n` +
49
- `- read_docx -> adeu extract ${filePath}\n` +
50
- `- process_document_batch -> adeu apply ${filePath}\n` +
51
- `- diff_docx_files -> adeu diff ${filePath} <modified_path>\n` +
52
- `- accept_all_changes -> adeu accept-all ${filePath}\n\n` +
53
- `To run the local tool, install it via:\n` +
54
- ` uv tool install adeu\n` +
55
- `and run the mapped CLI command directly in your terminal.`
45
+ `If you are running in a sandboxed/containerized environment (such as Claude Desktop or another containerized client), ` +
46
+ `the host application or MCP server may not have direct access to your local workspace files.\n` +
47
+ `You can resolve this by installing and running the local 'adeu' CLI tool directly within your environment.\n` +
48
+ `Here is how the MCP tools map to their CLI equivalents:\n` +
49
+ `- read_docx -> adeu extract ${filePath}\n` +
50
+ `- process_document_batch -> adeu apply ${filePath}\n` +
51
+ `- diff_docx_files -> adeu diff ${filePath} <modified_path>\n` +
52
+ `- accept_all_changes -> adeu accept-all ${filePath}\n\n` +
53
+ `To run the local tool, install it via:\n` +
54
+ ` uv tool install adeu\n` +
55
+ `and run the mapped CLI command directly in your terminal.`,
56
56
  );
57
57
  }
58
58
  throw err;
@@ -83,10 +83,10 @@ const READ_DOCX_TAIL =
83
83
  const PROCESS_BATCH_COMMON_DESC =
84
84
  "Applies a batch of edits and review actions to a DOCX.\n\nAll changes evaluate against the ORIGINAL document state — 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";
85
85
  const PROCESS_BATCH_OPERATIONS_DESC =
86
- "Each item in `changes` must specify a `type`:\n1. 'modify': Search-and-replace. `target_text` must uniquely matchinclude 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 — 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 — 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 — 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.";
86
+ "Each item in `changes` must specify a `type`:\n1. 'modify': Search-and-replace. By default `target_text` must match uniquely (`match_mode`:'strict')add surrounding context to disambiguate, or set `match_mode`:'first'/'all' to edit the first or every occurrence. Set `regex`:true to treat `target_text` as a regular expression (capture groups available in `new_text` as $1, $2…). `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 — 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 — 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 — 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.";
87
87
 
88
88
  const DIFF_DOCX_DESC =
89
- "Compares two DOCX files and returns a unified diff of their text content. Useful for analyzing differences between versions before editing.";
89
+ "Compares two DOCX files and returns a compact `@@ Word Patch @@` diff — Adeu's token-level, sub-word patch format — of their text content. Useful for analyzing differences between versions before editing.";
90
90
 
91
91
  const gitSha = process.env.GIT_SHA || "unknown";
92
92
  const packageVersion = process.env.PACKAGE_VERSION || "unknown";
@@ -95,7 +95,9 @@ const buildTag = ` [Adeu v${packageVersion}+${gitSha}]`;
95
95
  // --- Scope Configuration ---
96
96
  const args = process.argv.slice(2);
97
97
  const scopeIdx = args.indexOf("--scope");
98
- const requestedScope = (scopeIdx !== -1 ? args[scopeIdx + 1] : "all").toLowerCase();
98
+ const requestedScope = (
99
+ scopeIdx !== -1 ? args[scopeIdx + 1] : "all"
100
+ ).toLowerCase();
99
101
  const isDocxOnly = requestedScope === "docx";
100
102
 
101
103
  // --- Server Setup ---
@@ -108,7 +110,9 @@ const server = new McpServer({
108
110
  const originalRegisterTool = server.registerTool.bind(server);
109
111
  server.registerTool = (name: string, schema: any, handler?: any) => {
110
112
  if (schema && typeof schema === "object") {
111
- if (schema.description) {
113
+ // Idempotent: UI tools route through BOTH this wrapper and the
114
+ // registerAppTool wrapper, so guard against stamping the tag twice.
115
+ if (schema.description && !schema.description.includes(buildTag.trim())) {
112
116
  schema.description = schema.description.trim() + buildTag;
113
117
  }
114
118
  }
@@ -116,7 +120,12 @@ server.registerTool = (name: string, schema: any, handler?: any) => {
116
120
  };
117
121
 
118
122
  // Wrap registerAppTool to inject buildTag into descriptions
119
- const registerAppTool: typeof origRegisterAppTool = (mcpServer, name, schema, handler) => {
123
+ const registerAppTool: typeof origRegisterAppTool = (
124
+ mcpServer,
125
+ name,
126
+ schema,
127
+ handler,
128
+ ) => {
120
129
  if (schema && typeof schema === "object") {
121
130
  if (schema.description) {
122
131
  schema.description = schema.description.trim() + buildTag;
@@ -226,10 +235,12 @@ registerAppTool(
226
235
  ),
227
236
  page: z
228
237
  .union([z.number(), z.string()])
229
- .default(1)
230
- .describe("Page number (1-indexed) for mode='full', or 'all' to return unpaginated."),
231
- outline_max_level: z
232
- .coerce.number()
238
+ .optional()
239
+ .describe(
240
+ "Without `search_query`: 1-indexed document page to display (defaults to 1). With `search_query`: restricts matches to that document page (defaults to searching all pages; pass `page='all'` to be explicit).",
241
+ ),
242
+ outline_max_level: z.coerce
243
+ .number()
233
244
  .default(2)
234
245
  .describe("For mode='outline' only: cap on heading depth."),
235
246
  outline_verbose: z
@@ -239,11 +250,15 @@ registerAppTool(
239
250
  search_query: z
240
251
  .string()
241
252
  .optional()
242
- .describe("The substring or regex pattern to search for. When provided, filters results to matching paragraphs."),
253
+ .describe(
254
+ "The substring or regex pattern to search for. When provided, filters results to matching paragraphs.",
255
+ ),
243
256
  search_regex: z
244
257
  .boolean()
245
258
  .default(false)
246
- .describe("Set to true to interpret search_query as a regular expression."),
259
+ .describe(
260
+ "Set to true to interpret search_query as a regular expression.",
261
+ ),
247
262
  search_case_sensitive: z
248
263
  .boolean()
249
264
  .default(true)
@@ -267,7 +282,12 @@ registerAppTool(
267
282
 
268
283
  if (mode === "outline") {
269
284
  const doc = await DocumentObject.load(buf);
270
- const extract_res = _extractTextFromDoc(doc, clean_view, true, true) as {
285
+ const extract_res = _extractTextFromDoc(
286
+ doc,
287
+ clean_view,
288
+ true,
289
+ true,
290
+ ) as {
271
291
  text: string;
272
292
  paragraph_offsets: Map<any, [number, number]>;
273
293
  };
@@ -284,14 +304,29 @@ registerAppTool(
284
304
 
285
305
  const text = await extractTextFromBuffer(buf, clean_view);
286
306
  if (search_query !== undefined && search_query !== null) {
287
- const res = build_search_response(text, search_query, search_regex, search_case_sensitive, page, file_path);
307
+ // In search mode, undefined `page` means "search all document pages".
308
+ const res = build_search_response(
309
+ text,
310
+ search_query,
311
+ search_regex,
312
+ search_case_sensitive,
313
+ page,
314
+ file_path,
315
+ );
288
316
  return res as any;
289
317
  }
318
+ // In non-search mode, `page` defaults to 1 (show document page 1).
319
+ const resolvedPage =
320
+ page === undefined || page === null
321
+ ? 1
322
+ : typeof page === "number"
323
+ ? page
324
+ : parseInt(String(page), 10) || 1;
290
325
  if (mode === "appendix") {
291
- const res = build_appendix_response(text, typeof page === "number" ? page : parseInt(page, 10) || 1, file_path);
326
+ const res = build_appendix_response(text, resolvedPage, file_path);
292
327
  return res as any;
293
328
  }
294
- const res = build_paginated_response(text, typeof page === "number" ? page : parseInt(page, 10) || 1, file_path);
329
+ const res = build_paginated_response(text, resolvedPage, file_path);
295
330
  return res as any;
296
331
  } catch (e: any) {
297
332
  return {
@@ -311,6 +346,69 @@ registerAppTool(
311
346
  // 3. HEADLESS TOOLS (No UI)
312
347
  // ==========================================
313
348
 
349
+ // Typed shape for a single `process_document_batch` change. This makes the six
350
+ // DocumentChange variants — and the modify-only `match_mode`/`regex` options —
351
+ // discoverable from the tool schema itself, instead of prose alone. A bare
352
+ // string is still accepted (and normalized in-handler) so double-serialized
353
+ // payloads from some LLM clients keep working; only `type` is required, all
354
+ // other fields are optional, and unknown keys pass through untouched.
355
+ const CHANGE_ITEM_SCHEMA = z
356
+ .object({
357
+ type: z
358
+ .enum(["modify", "accept", "reject", "reply", "insert_row", "delete_row"])
359
+ .describe(
360
+ "Change kind: 'modify' (search-and-replace), 'accept'/'reject' (resolve a tracked change by id), 'reply' (reply to a comment by id), 'insert_row'/'delete_row' (table edits; disk mode only).",
361
+ ),
362
+ target_text: z
363
+ .string()
364
+ .optional()
365
+ .describe(
366
+ "modify / insert_row / delete_row: the existing text to locate (interpreted as a regex when regex=true).",
367
+ ),
368
+ new_text: z
369
+ .string()
370
+ .optional()
371
+ .describe(
372
+ "modify: replacement text. Supports Markdown (headings, **bold**, _italic_, '\\n\\n' paragraph splits); empty string deletes. Regex capture groups are available as $1, $2…",
373
+ ),
374
+ target_id: z
375
+ .string()
376
+ .optional()
377
+ .describe(
378
+ "accept / reject / reply: the 'Chg:N' or 'Com:N' id taken from a fresh read_docx.",
379
+ ),
380
+ text: z.string().optional().describe("reply: the reply body."),
381
+ comment: z
382
+ .string()
383
+ .optional()
384
+ .describe(
385
+ "modify / accept / reject: attach a margin comment to the change (no manual CriticMarkup).",
386
+ ),
387
+ match_mode: z
388
+ .enum(["strict", "first", "all"])
389
+ .optional()
390
+ .describe(
391
+ "modify only: 'strict' (default — target must match uniquely), 'first' (first occurrence), or 'all' (every occurrence).",
392
+ ),
393
+ regex: z
394
+ .boolean()
395
+ .optional()
396
+ .describe(
397
+ "modify only: treat target_text as a regular expression (default false).",
398
+ ),
399
+ position: z
400
+ .enum(["above", "below"])
401
+ .optional()
402
+ .describe(
403
+ "insert_row: place the new row above or below the matched row.",
404
+ ),
405
+ cells: z
406
+ .array(z.string())
407
+ .optional()
408
+ .describe("insert_row: the cell values for the new row, left to right."),
409
+ })
410
+ .passthrough();
411
+
314
412
  server.registerTool(
315
413
  "process_document_batch",
316
414
  {
@@ -323,8 +421,10 @@ server.registerTool(
323
421
  .string()
324
422
  .describe("Name to appear in Track Changes (e.g., 'Reviewer AI')."),
325
423
  changes: z
326
- .array(z.any())
327
- .describe("List of changes to apply. Each change must specify 'type'."),
424
+ .array(z.union([z.string(), CHANGE_ITEM_SCHEMA]))
425
+ .describe(
426
+ "Ordered list of changes to apply. Each item is an object carrying a `type` discriminator plus that type's fields (see the per-field docs and the tool description). All items evaluate against the ORIGINAL document state.",
427
+ ),
328
428
  output_path: z.string().optional().describe("Optional output path."),
329
429
  dry_run: z
330
430
  .boolean()
@@ -516,7 +616,7 @@ server.registerTool(
516
616
  "finalize_document",
517
617
  {
518
618
  description:
519
- "Prepares a document for external distribution or e-signature.",
619
+ "Prepares a document for external distribution or e-signature. Note: in this zero-dependency environment, protection_mode='encrypt' is unsupported and falls back to a native read-only lock; export_pdf and password are ignored.",
520
620
  inputSchema: {
521
621
  file_path: z.string().describe("Absolute path to the DOCX file."),
522
622
  output_path: z.string().optional().describe("Optional output path."),
@@ -533,7 +633,9 @@ server.registerTool(
533
633
  protection_mode: z
534
634
  .enum(["read_only", "encrypt"])
535
635
  .optional()
536
- .describe("Native OOXML document locking."),
636
+ .describe(
637
+ "Native OOXML document locking. Note: 'encrypt' is unsupported in this zero-dependency build and falls back to 'read_only'.",
638
+ ),
537
639
  password: z.string().optional().describe("Ignored in this environment."),
538
640
  author: z
539
641
  .string()
@@ -595,145 +697,145 @@ server.registerTool(
595
697
  );
596
698
 
597
699
  if (!isDocxOnly) {
598
- registerAppTool(
599
- server,
600
- "search_and_fetch_emails",
601
- {
602
- title: "Search & Fetch Emails",
603
- description:
604
- "Searches the user's live email inbox via the Adeu cloud backend.\n\n" +
605
- "TWO MODES:\n" +
606
- "1. 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.\n" +
607
- "2. 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\n" +
608
- "AUTO-ESCALATION: If a search returns exactly one preview, the backend automatically fetches the full email in the same call. Plan around the response shape — check the `type` field (`previews` vs `full_email`) before assuming.\n\n" +
609
- "EMAIL ID FORMATS (`email_id` parameter accepts any of):\n" +
610
- "- `msg_<6 chars>` — 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" +
611
- "- `adeu_<numeric>` — server-side reference for emails Adeu has previously processed. Portable across machines and sessions for the same authenticated user.\n" +
612
- "- Raw provider ID (Gmail/Outlook native ID) — works if you have it, but you usually won't.\n\n" +
613
- "FOLDER 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\n" +
614
- "ATTACHMENTS: attachments larger than `max_attachment_size_mb` (default 10) are listed in the response but NOT downloaded — 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.",
615
- inputSchema: z.object({
616
- sender: z.string().optional(),
617
- subject: z.string().optional(),
618
- has_attachments: z.boolean().optional(),
619
- attachment_name: z.string().optional(),
620
- is_unread: z.boolean().optional(),
621
- days_ago: z.coerce.number().optional(),
622
- folder: z.enum(["inbox", "sent", "all"]).optional(),
623
- limit: z.coerce.number().default(10),
624
- offset: z.coerce.number().default(0),
625
- email_id: z.string().optional(),
626
- working_directory: z.string().optional(),
627
- mailbox_address: z
628
- .string()
629
- .optional()
630
- .describe("Optional target mailbox email address to search within."),
631
- task_id: z
632
- .string()
633
- .optional()
634
- .describe("If resuming a pending check, provide the task ID here."),
635
- max_attachment_size_mb: z
636
- .coerce.number()
637
- .optional()
638
- .describe(
639
- "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.",
640
- ),
641
- }),
642
- _meta: { ui: { resourceUri: EMAIL_UI_URI } },
643
- },
644
- async (args) => {
645
- try {
646
- return (await search_and_fetch_emails(args)) as any;
647
- } catch (e: any) {
648
- return {
649
- isError: true,
650
- content: [{ type: "text", text: e.message }],
651
- };
652
- }
653
- },
654
- );
700
+ registerAppTool(
701
+ server,
702
+ "search_and_fetch_emails",
703
+ {
704
+ title: "Search & Fetch Emails",
705
+ description:
706
+ "Searches the user's live email inbox via the Adeu cloud backend.\n\n" +
707
+ "TWO MODES:\n" +
708
+ "1. 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.\n" +
709
+ "2. 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\n" +
710
+ "AUTO-ESCALATION: If a search returns exactly one preview, the backend automatically fetches the full email in the same call. Plan around the response shape — check the `type` field (`previews` vs `full_email`) before assuming.\n\n" +
711
+ "EMAIL ID FORMATS (`email_id` parameter accepts any of):\n" +
712
+ "- `msg_<6 chars>` — 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" +
713
+ "- `adeu_<numeric>` — server-side reference for emails Adeu has previously processed. Portable across machines and sessions for the same authenticated user.\n" +
714
+ "- Raw provider ID (Gmail/Outlook native ID) — works if you have it, but you usually won't.\n\n" +
715
+ "FOLDER 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\n" +
716
+ "ATTACHMENTS: attachments larger than `max_attachment_size_mb` (default 10) are listed in the response but NOT downloaded — 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.",
717
+ inputSchema: z.object({
718
+ sender: z.string().optional(),
719
+ subject: z.string().optional(),
720
+ has_attachments: z.boolean().optional(),
721
+ attachment_name: z.string().optional(),
722
+ is_unread: z.boolean().optional(),
723
+ days_ago: z.coerce.number().optional(),
724
+ folder: z.enum(["inbox", "sent", "all"]).optional(),
725
+ limit: z.coerce.number().default(10),
726
+ offset: z.coerce.number().default(0),
727
+ email_id: z.string().optional(),
728
+ working_directory: z.string().optional(),
729
+ mailbox_address: z
730
+ .string()
731
+ .optional()
732
+ .describe("Optional target mailbox email address to search within."),
733
+ task_id: z
734
+ .string()
735
+ .optional()
736
+ .describe("If resuming a pending check, provide the task ID here."),
737
+ max_attachment_size_mb: z.coerce
738
+ .number()
739
+ .optional()
740
+ .describe(
741
+ "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.",
742
+ ),
743
+ }),
744
+ _meta: { ui: { resourceUri: EMAIL_UI_URI } },
745
+ },
746
+ async (args) => {
747
+ try {
748
+ return (await search_and_fetch_emails(args)) as any;
749
+ } catch (e: any) {
750
+ return {
751
+ isError: true,
752
+ content: [{ type: "text", text: e.message }],
753
+ };
754
+ }
755
+ },
756
+ );
655
757
 
656
- server.registerTool(
657
- "login_to_adeu_cloud",
658
- {
659
- description:
660
- "Logs the user into Adeu Cloud. Opens a browser window for SSO authentication.\n\n" +
661
- "IMPORTANT — login is user-level, not account-level:\n" +
662
- "- An Adeu user can have multiple linked provider accounts (Microsoft, Google) and multiple mailboxes (personal + shared/delegated). One linked account is marked primary.\n" +
663
- "- Signing in through ANY of the user's linked accounts authenticates the same Adeu user. Once logged in, the session can read from and draft in ALL of that user's linked accounts and ALL of their mailboxes — not just the one used to sign in.\n" +
664
- "- The choice of which provider account to sign in through is purely an SSO mechanism; it does not select a 'current account' for the session.\n\n" +
665
- "When the user asks which accounts or mailboxes are available, call `list_available_mailboxes` rather than naming a single account from the login response.",
666
- },
667
- async () => {
668
- try {
669
- return (await login_to_adeu_cloud()) as any;
670
- } catch (e: any) {
671
- return { isError: true, content: [{ type: "text", text: e.message }] };
672
- }
673
- },
674
- );
758
+ server.registerTool(
759
+ "login_to_adeu_cloud",
760
+ {
761
+ description:
762
+ "Logs the user into Adeu Cloud. Opens a browser window for SSO authentication.\n\n" +
763
+ "IMPORTANT — login is user-level, not account-level:\n" +
764
+ "- An Adeu user can have multiple linked provider accounts (Microsoft, Google) and multiple mailboxes (personal + shared/delegated). One linked account is marked primary.\n" +
765
+ "- Signing in through ANY of the user's linked accounts authenticates the same Adeu user. Once logged in, the session can read from and draft in ALL of that user's linked accounts and ALL of their mailboxes — not just the one used to sign in.\n" +
766
+ "- The choice of which provider account to sign in through is purely an SSO mechanism; it does not select a 'current account' for the session.\n\n" +
767
+ "When the user asks which accounts or mailboxes are available, call `list_available_mailboxes` rather than naming a single account from the login response.",
768
+ },
769
+ async () => {
770
+ try {
771
+ return (await login_to_adeu_cloud()) as any;
772
+ } catch (e: any) {
773
+ return { isError: true, content: [{ type: "text", text: e.message }] };
774
+ }
775
+ },
776
+ );
675
777
 
676
- server.registerTool(
677
- "logout_of_adeu_cloud",
678
- { description: "Logs out of the Adeu Cloud backend." },
679
- async () => {
680
- try {
681
- return (await logout_of_adeu_cloud()) as any;
682
- } catch (e: any) {
683
- return { isError: true, content: [{ type: "text", text: e.message }] };
684
- }
685
- },
686
- );
687
- server.registerTool(
688
- "create_email_draft",
689
- {
690
- description:
691
- "Creates an email draft in the user's native draft box (Outlook Drafts or Gmail Drafts).\n\n" +
692
- "TWO MODES:\n" +
693
- "1. Reply mode: pass `reply_to_email_id` to create a threaded reply. The draft inherits subject, recipients, and threading headers from the original — do NOT pass `subject` or `to_recipients`.\n" +
694
- "2. New email mode: omit `reply_to_email_id` and pass BOTH `subject` and `to_recipients`.\n\n" +
695
- "`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" +
696
- "`body_markdown` is converted server-side to styled HTML with inlined CSS for email-client compatibility. Write the body in plain Markdown — do not pre-render HTML.\n\n" +
697
- "`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 — those local paths can be passed directly here.",
698
- inputSchema: {
699
- body_markdown: z.string(),
700
- reply_to_email_id: z.string().optional(),
701
- subject: z.string().optional(),
702
- to_recipients: z.array(z.string()).optional(),
703
- attachment_paths: z.array(z.string()).optional(),
704
- mailbox_address: z
705
- .string()
706
- .optional()
707
- .describe(
708
- "Optional target mailbox email address to create the draft in.",
709
- ),
778
+ server.registerTool(
779
+ "logout_of_adeu_cloud",
780
+ { description: "Logs out of the Adeu Cloud backend." },
781
+ async () => {
782
+ try {
783
+ return (await logout_of_adeu_cloud()) as any;
784
+ } catch (e: any) {
785
+ return { isError: true, content: [{ type: "text", text: e.message }] };
786
+ }
710
787
  },
711
- },
712
- async (args) => {
713
- try {
714
- return (await create_email_draft(args)) as any;
715
- } catch (e: any) {
716
- return { isError: true, content: [{ type: "text", text: e.message }] };
717
- }
718
- },
719
- );
720
- server.registerTool(
721
- "list_available_mailboxes",
722
- {
723
- description:
724
- "Lists all personal and shared/delegated mailboxes the authenticated Adeu user has access to, across ALL of their linked provider accounts. Returns each mailbox's `email_address`, `display_name`, auto-processing settings, and write-back preference.\n\n" +
725
- "This is the right tool to answer 'which accounts/mailboxes am I logged into?' — Adeu login is user-level, so a single MCP session can see every mailbox listed here regardless of which provider account was used for SSO.\n\n" +
726
- "Call this FIRST when the user names a specific mailbox or shared inbox, 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. Omitting `mailbox_address` on those tools targets the user's primary personal mailbox.",
727
- inputSchema: {},
728
- },
729
- async () => {
730
- try {
731
- return (await list_available_mailboxes()) as any;
732
- } catch (e: any) {
733
- return { isError: true, content: [{ type: "text", text: e.message }] };
734
- }
735
- },
736
- );
788
+ );
789
+ server.registerTool(
790
+ "create_email_draft",
791
+ {
792
+ description:
793
+ "Creates an email draft in the user's native draft box (Outlook Drafts or Gmail Drafts).\n\n" +
794
+ "TWO MODES:\n" +
795
+ "1. Reply mode: pass `reply_to_email_id` to create a threaded reply. The draft inherits subject, recipients, and threading headers from the original — do NOT pass `subject` or `to_recipients`.\n" +
796
+ "2. New email mode: omit `reply_to_email_id` and pass BOTH `subject` and `to_recipients`.\n\n" +
797
+ "`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" +
798
+ "`body_markdown` is converted server-side to styled HTML with inlined CSS for email-client compatibility. Write the body in plain Markdown — do not pre-render HTML.\n\n" +
799
+ "`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 — those local paths can be passed directly here.",
800
+ inputSchema: {
801
+ body_markdown: z.string(),
802
+ reply_to_email_id: z.string().optional(),
803
+ subject: z.string().optional(),
804
+ to_recipients: z.array(z.string()).optional(),
805
+ attachment_paths: z.array(z.string()).optional(),
806
+ mailbox_address: z
807
+ .string()
808
+ .optional()
809
+ .describe(
810
+ "Optional target mailbox email address to create the draft in.",
811
+ ),
812
+ },
813
+ },
814
+ async (args) => {
815
+ try {
816
+ return (await create_email_draft(args)) as any;
817
+ } catch (e: any) {
818
+ return { isError: true, content: [{ type: "text", text: e.message }] };
819
+ }
820
+ },
821
+ );
822
+ server.registerTool(
823
+ "list_available_mailboxes",
824
+ {
825
+ description:
826
+ "Lists all personal and shared/delegated mailboxes the authenticated Adeu user has access to, across ALL of their linked provider accounts. Returns each mailbox's `email_address`, `display_name`, auto-processing settings, and write-back preference.\n\n" +
827
+ "This is the right tool to answer 'which accounts/mailboxes am I logged into?' — Adeu login is user-level, so a single MCP session can see every mailbox listed here regardless of which provider account was used for SSO.\n\n" +
828
+ "Call this FIRST when the user names a specific mailbox or shared inbox, 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. Omitting `mailbox_address` on those tools targets the user's primary personal mailbox.",
829
+ inputSchema: {},
830
+ },
831
+ async () => {
832
+ try {
833
+ return (await list_available_mailboxes()) as any;
834
+ } catch (e: any) {
835
+ return { isError: true, content: [{ type: "text", text: e.message }] };
836
+ }
837
+ },
838
+ );
737
839
  }
738
840
 
739
841
  // --- Formatter for process_document_batch ---
@@ -748,8 +850,17 @@ export function formatBatchResult(
748
850
  } else {
749
851
  res = `Batch complete. Saved to: ${outPath}\n`;
750
852
  }
751
- const total_occurrences = stats.edits ? stats.edits.reduce((acc: number, e: any) => acc + (e.status === "applied" ? (e.occurrences_modified || 1) : 0), 0) : 0;
752
- const occ_text = total_occurrences > stats.edits_applied ? ` (${total_occurrences} occurrences)` : "";
853
+ const total_occurrences = stats.edits
854
+ ? stats.edits.reduce(
855
+ (acc: number, e: any) =>
856
+ acc + (e.status === "applied" ? e.occurrences_modified || 1 : 0),
857
+ 0,
858
+ )
859
+ : 0;
860
+ const occ_text =
861
+ total_occurrences > stats.edits_applied
862
+ ? ` (${total_occurrences} occurrences)`
863
+ : "";
753
864
 
754
865
  res += `Actions: ${stats.actions_applied} applied, ${stats.actions_skipped} skipped.\n`;
755
866
  res += `Edits: ${stats.edits_applied} applied${occ_text}, ${stats.edits_skipped} skipped.\n`;
@@ -760,20 +871,22 @@ export function formatBatchResult(
760
871
  const report = stats.edits[i];
761
872
  const status_indicator =
762
873
  report.status === "applied" ? "✅ [applied]" : "❌ [failed]";
763
-
764
- const pagesStr = report.pages && report.pages.length > 0
765
- ? ` (p${report.pages.join(", p")})`
766
- : "";
767
-
874
+
875
+ const pagesStr =
876
+ report.pages && report.pages.length > 0
877
+ ? ` (p${report.pages.join(", p")})`
878
+ : "";
879
+
768
880
  res += `### Edit ${i + 1} ${status_indicator}${pagesStr}\n`;
769
-
881
+
770
882
  if (report.heading_path) {
771
883
  res += `**Path:** \`${report.heading_path}\`\n`;
772
884
  }
773
-
885
+
774
886
  if (report.match_mode) {
775
- const occ = report.occurrences_modified || (report.status === "applied" ? 1 : 0);
776
- res += `**Mode:** \`${report.match_mode}\` (${occ} occurrence${occ !== 1 ? 's' : ''} modified)\n`;
887
+ const occ =
888
+ report.occurrences_modified || (report.status === "applied" ? 1 : 0);
889
+ res += `**Mode:** \`${report.match_mode}\` (${occ} occurrence${occ !== 1 ? "s" : ""} modified)\n`;
777
890
  }
778
891
 
779
892
  if (report.error) {
@@ -782,12 +895,12 @@ export function formatBatchResult(
782
895
  if (report.warning) {
783
896
  res += `*Warning:* ${report.warning}\n`;
784
897
  }
785
-
898
+
786
899
  if (report.critic_markup) {
787
- res += `*Preview (CriticMarkup):*\n> ${report.critic_markup.split('\\n').join('\\n> ')}\n`;
900
+ res += `*Preview (CriticMarkup):*\n> ${report.critic_markup.split("\\n").join("\\n> ")}\n`;
788
901
  }
789
902
  if (report.clean_text) {
790
- res += `*Preview (Clean):*\n> ${report.clean_text.split('\\n').join('\\n> ')}\n`;
903
+ res += `*Preview (Clean):*\n> ${report.clean_text.split("\\n").join("\\n> ")}\n`;
791
904
  }
792
905
  res += "\n";
793
906
  }