@adeu/mcp-server 1.9.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/src/index.ts CHANGED
@@ -241,7 +241,17 @@ registerAppTool(
241
241
  {
242
242
  title: "Search & Fetch Emails",
243
243
  description:
244
- "Searches the user's live email inbox. Returns previews. Call again with `email_id` to fetch the full body.",
244
+ "Searches the user's live email inbox via the Adeu cloud backend.\n\n" +
245
+ "TWO MODES:\n" +
246
+ "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" +
247
+ "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" +
248
+ "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" +
249
+ "EMAIL ID FORMATS (`email_id` parameter accepts any of):\n" +
250
+ "- `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" +
251
+ "- `adeu_<numeric>` — server-side reference for emails Adeu has previously processed. Portable across machines and sessions for the same authenticated user.\n" +
252
+ "- Raw provider ID (Gmail/Outlook native ID) — works if you have it, but you usually won't.\n\n" +
253
+ "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" +
254
+ "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.",
245
255
  inputSchema: z.object({
246
256
  sender: z.string().optional(),
247
257
  subject: z.string().optional(),
@@ -258,6 +268,12 @@ registerAppTool(
258
268
  .string()
259
269
  .optional()
260
270
  .describe("Optional target mailbox email address to search within."),
271
+ max_attachment_size_mb: z
272
+ .number()
273
+ .optional()
274
+ .describe(
275
+ "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.",
276
+ ),
261
277
  }),
262
278
  _meta: { ui: { resourceUri: EMAIL_UI_URI } },
263
279
  },
@@ -267,12 +283,7 @@ registerAppTool(
267
283
  } catch (e: any) {
268
284
  return {
269
285
  isError: true,
270
- content: [
271
- {
272
- type: "text",
273
- text: `Error executing tool search_and_fetch_emails: ${e.message}`,
274
- },
275
- ],
286
+ content: [{ type: "text", text: e.message }],
276
287
  };
277
288
  }
278
289
  },
@@ -297,9 +308,14 @@ server.registerTool(
297
308
  .array(z.any())
298
309
  .describe("List of changes to apply. Each change must specify 'type'."),
299
310
  output_path: z.string().optional().describe("Optional output path."),
311
+ dry_run: z
312
+ .boolean()
313
+ .optional()
314
+ .default(false)
315
+ .describe("If True, simulates the changes and returns a detailed preview report without modifying any files."),
300
316
  },
301
317
  },
302
- async ({ original_docx_path, author_name, changes, output_path }) => {
318
+ async ({ original_docx_path, author_name, changes, output_path, dry_run }) => {
303
319
  try {
304
320
  if (!author_name || !author_name.trim())
305
321
  return {
@@ -326,7 +342,7 @@ server.registerTool(
326
342
 
327
343
  let stats;
328
344
  try {
329
- stats = engine.process_batch(changes);
345
+ stats = engine.process_batch(changes, dry_run);
330
346
  } catch (e: any) {
331
347
  if (e instanceof BatchValidationError) {
332
348
  return {
@@ -342,14 +358,12 @@ server.registerTool(
342
358
  throw e;
343
359
  }
344
360
 
345
- const outBuf = await doc.save();
346
-
347
- fs.writeFileSync(outPath, outBuf);
348
-
349
- let res = `Batch complete. Saved to: ${outPath}\nActions: ${stats.actions_applied} applied, ${stats.actions_skipped} skipped.\nEdits: ${stats.edits_applied} applied, ${stats.edits_skipped} skipped.`;
350
- if (stats.skipped_details?.length > 0) {
351
- res += `\n\nSkipped Details:\n${stats.skipped_details.join("\n")}`;
361
+ if (!dry_run) {
362
+ const outBuf = await doc.save();
363
+ fs.writeFileSync(outPath, outBuf);
352
364
  }
365
+
366
+ const res = formatBatchResult(stats, outPath, !!dry_run);
353
367
  return { content: [{ type: "text", text: res }] };
354
368
  } catch (e: any) {
355
369
  return {
@@ -557,7 +571,14 @@ server.registerTool(
557
571
  server.registerTool(
558
572
  "create_email_draft",
559
573
  {
560
- description: "Creates an email draft in the user's native draft box.",
574
+ description:
575
+ "Creates an email draft in the user's native draft box (Outlook Drafts or Gmail Drafts).\n\n" +
576
+ "TWO MODES:\n" +
577
+ "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" +
578
+ "2. New email mode: omit `reply_to_email_id` and pass BOTH `subject` and `to_recipients`.\n\n" +
579
+ "`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" +
580
+ "`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" +
581
+ "`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.",
561
582
  inputSchema: {
562
583
  body_markdown: z.string(),
563
584
  reply_to_email_id: z.string().optional(),
@@ -584,7 +605,9 @@ server.registerTool(
584
605
  "list_available_mailboxes",
585
606
  {
586
607
  description:
587
- "Lists all personal and shared delegated mailboxes configured for the authenticated profile. Use this to discover valid email addresses to scope search and draft operations.",
608
+ "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\n" +
609
+ "Call 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\n" +
610
+ "Omitting `mailbox_address` on those tools targets the user's primary personal mailbox.",
588
611
  inputSchema: {},
589
612
  },
590
613
  async () => {
@@ -595,6 +618,50 @@ server.registerTool(
595
618
  }
596
619
  },
597
620
  );
621
+ // --- Formatter for process_document_batch ---
622
+ export function formatBatchResult(
623
+ stats: any,
624
+ outPath: string,
625
+ dry_run: boolean,
626
+ ): string {
627
+ let res = "";
628
+ if (dry_run) {
629
+ res = `Dry-run simulation complete.\n`;
630
+ } else {
631
+ res = `Batch complete. Saved to: ${outPath}\n`;
632
+ }
633
+ res += `Actions: ${stats.actions_applied} applied, ${stats.actions_skipped} skipped.\n`;
634
+ res += `Edits: ${stats.edits_applied} applied, ${stats.edits_skipped} skipped.\n`;
635
+
636
+ if (stats.edits && stats.edits.length > 0) {
637
+ res += "\nDetailed Edit Reports:\n";
638
+ for (let i = 0; i < stats.edits.length; i++) {
639
+ const report = stats.edits[i];
640
+ const status_indicator = report.status === "applied" ? "✅ [applied]" : "❌ [failed]";
641
+ res += `Edit ${i + 1} ${status_indicator}:\n`;
642
+ res += ` Target: '${report.target_text}'\n`;
643
+ res += ` New text: '${report.new_text}'\n`;
644
+ if (report.warning) {
645
+ res += ` Warning: ${report.warning}\n`;
646
+ }
647
+ if (report.error) {
648
+ res += ` Error: ${report.error}\n`;
649
+ }
650
+ if (report.critic_markup) {
651
+ res += ` Preview (CriticMarkup): ${report.critic_markup}\n`;
652
+ }
653
+ if (report.clean_text) {
654
+ res += ` Clean text preview: ${report.clean_text}\n`;
655
+ }
656
+ }
657
+ }
658
+
659
+ if (stats.skipped_details && stats.skipped_details.length > 0) {
660
+ res += `\n\nSkipped Details:\n${stats.skipped_details.join("\n")}`;
661
+ }
662
+ return res;
663
+ }
664
+
598
665
  // --- Startup ---
599
666
  async function main() {
600
667
  const transport = new StdioServerTransport();
package/src/tools/auth.ts CHANGED
@@ -12,6 +12,7 @@ export async function login_to_adeu_cloud(): Promise<ToolResult> {
12
12
  Authorization: `Bearer ${apiKey}`,
13
13
  Accept: "application/json",
14
14
  },
15
+ signal: AbortSignal.timeout(15_000),
15
16
  });
16
17
 
17
18
  if (res.status === 401) {
@@ -0,0 +1,258 @@
1
+ // FILE: node/packages/mcp-server/src/tools/email.test.ts
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import { search_and_fetch_emails, list_available_mailboxes } from "./email.js";
4
+
5
+ // Mock the Auth module so tests bypass active browser logins
6
+ vi.mock("../desktop-auth.js", () => {
7
+ return {
8
+ getCloudAuthToken: vi.fn().mockResolvedValue("mock_token_abc"),
9
+ DesktopAuthManager: {
10
+ getApiKey: vi.fn().mockReturnValue("mock_token_abc"),
11
+ clearApiKey: vi.fn(),
12
+ },
13
+ };
14
+ });
15
+
16
+ describe("Node Email Tools Finding #2 and Finding #6 tests", () => {
17
+ const originalFetch = global.fetch;
18
+
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ });
22
+
23
+ afterEach(() => {
24
+ global.fetch = originalFetch;
25
+ });
26
+
27
+ it("Finding #6: Correctly handles stale msg_ short IDs inside tool boundary", async () => {
28
+ const result = await search_and_fetch_emails({
29
+ email_id: "msg_stale99",
30
+ });
31
+
32
+ expect(result.isError).toBe(true);
33
+ const text = result.content[0].text;
34
+ expect(text).toContain("is not in the local cache");
35
+ expect(text).toContain("evicted, or it came from a different machine");
36
+ expect(text).toContain("Re-run search_and_fetch_emails with filters");
37
+ });
38
+
39
+ it("Finding #6: Maps backend Mailbox Not Found 404 error to Node boundary ToolError", async () => {
40
+ // Stub fetch to simulate a 404 response with Mailbox error body
41
+ global.fetch = vi.fn().mockResolvedValue({
42
+ ok: false,
43
+ status: 404,
44
+ text: async () =>
45
+ JSON.stringify({
46
+ detail: "Mailbox 'bogus@nowhere.invalid' not found.",
47
+ }),
48
+ } as Response);
49
+
50
+ await expect(
51
+ search_and_fetch_emails({ mailbox_address: "bogus@nowhere.invalid" }),
52
+ ).rejects.toThrowError(
53
+ "Cloud search failed (HTTP 404): The mailbox 'bogus@nowhere.invalid' 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`.",
54
+ );
55
+ });
56
+
57
+ it("Finding #6: Maps backend Email Not Found 404 error to Node boundary ToolError", async () => {
58
+ // Stub fetch to simulate 404 for an invalid adeu_ ID (which bypasses local cache checks)
59
+ global.fetch = vi.fn().mockResolvedValue({
60
+ ok: false,
61
+ status: 404,
62
+ text: async () => JSON.stringify({ detail: "Email not found." }),
63
+ } as Response);
64
+
65
+ await expect(
66
+ search_and_fetch_emails({ email_id: "adeu_9999" }),
67
+ ).rejects.toThrowError(
68
+ "Cloud search failed (HTTP 404): 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 — 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.",
69
+ );
70
+ });
71
+
72
+ it("Finding #2: Asserts correct Markdown parity and Personal Mailbox fallback on list_available_mailboxes", async () => {
73
+ // Stub fetch to return one null-display mailbox and one alphabetical secondary mailbox
74
+ global.fetch = vi.fn().mockResolvedValue({
75
+ ok: true,
76
+ status: 200,
77
+ json: async () => [
78
+ {
79
+ email_address: "secondary@adeu.ai",
80
+ display_name: "Secondary Mailbox",
81
+ auto_process_enabled: false,
82
+ write_back_preference: "INTERNAL",
83
+ },
84
+ {
85
+ email_address: "primary@adeu.ai",
86
+ display_name: null, // Tests 'Personal Mailbox' fallback
87
+ auto_process_enabled: true,
88
+ write_back_preference: "DRAFT",
89
+ },
90
+ ],
91
+ } as Response);
92
+
93
+ const result = await list_available_mailboxes();
94
+ expect(result.isError).toBeFalsy();
95
+
96
+ const output = result.content[0].text;
97
+
98
+ // Verify preamble parity
99
+ expect(output).toContain("### Connected Mailboxes");
100
+ expect(output).toContain(
101
+ "Below is the list of connected mailboxes you have access to.",
102
+ );
103
+
104
+ // Verify Fallback formatting
105
+ expect(output).toContain("**Personal Mailbox**");
106
+
107
+ // Verify deterministic alphabetical sorting by email address (primary before secondary)
108
+ const idxPrimary = output.indexOf("primary@adeu.ai");
109
+ const idxSecondary = output.indexOf("secondary@adeu.ai");
110
+ expect(idxPrimary).not.toBe(-1);
111
+ expect(idxSecondary).not.toBe(-1);
112
+ expect(idxPrimary).toBeLessThan(idxSecondary);
113
+
114
+ // Verify labels formatting matches Python exactly
115
+ expect(output).toContain("- **Email Address**:");
116
+ expect(output).toContain("- **Auto-Processing**:");
117
+ expect(output).toContain("- **Write-Back Mode**:");
118
+ });
119
+
120
+ it("Finding #6: Gracefully maps generic, unmapped server errors on search_and_fetch_emails", async () => {
121
+ // Simulate generic HTTP 500 error
122
+ global.fetch = vi.fn().mockResolvedValue({
123
+ ok: false,
124
+ status: 500,
125
+ text: async () =>
126
+ JSON.stringify({ detail: "Database connection failed." }),
127
+ } as Response);
128
+
129
+ await expect(search_and_fetch_emails({ limit: 10 })).rejects.toThrowError(
130
+ "Cloud search failed (HTTP 500): Database connection failed.",
131
+ );
132
+ });
133
+
134
+ it("Finding #6: Converts abort/timeout errors to actionable ToolErrors on search_and_fetch_emails", async () => {
135
+ // Simulate a native fetch Timeout/AbortError
136
+ const timeoutError = new Error("The operation was aborted.");
137
+ timeoutError.name = "AbortError";
138
+ global.fetch = vi.fn().mockRejectedValue(timeoutError);
139
+
140
+ await expect(search_and_fetch_emails({ limit: 10 })).rejects.toThrowError(
141
+ "Email search timed out after 45s. The mail provider (Outlook/Gmail) may be slow.",
142
+ );
143
+ });
144
+
145
+ it("Findings #3, #9, and #11: Asserts preview pagination, auto-escalation note, and downstream tool suggests", async () => {
146
+ // --- Scenario 1: Previews listing with limit met (Finding #3 pagination hint) ---
147
+ const mockPreviewsPayload = {
148
+ type: "previews",
149
+ previews: [
150
+ {
151
+ id: "id1",
152
+ subject: "Subject 1",
153
+ sender_name: "Sender 1",
154
+ sender_email: "s1@adeu.ai",
155
+ received_datetime: "2026-01-01T12:00:00Z",
156
+ preview_text: "Text 1",
157
+ has_attachments: false,
158
+ is_read: true,
159
+ },
160
+ {
161
+ id: "id2",
162
+ subject: "Subject 2",
163
+ sender_name: "Sender 2",
164
+ sender_email: "s2@adeu.ai",
165
+ received_datetime: "2026-01-01T12:00:00Z",
166
+ preview_text: "Text 2",
167
+ has_attachments: false,
168
+ is_read: true,
169
+ },
170
+ ],
171
+ };
172
+
173
+ global.fetch = vi.fn().mockResolvedValue({
174
+ ok: true,
175
+ status: 200,
176
+ json: async () => mockPreviewsPayload,
177
+ } as Response);
178
+
179
+ const resPreviews = await search_and_fetch_emails({
180
+ subject: "Invoice",
181
+ limit: 2,
182
+ offset: 0,
183
+ });
184
+
185
+ const previewsText = resPreviews.content[0].text;
186
+ expect(previewsText).toContain(
187
+ "*(If you need to see more results, call this tool again with offset=2)*",
188
+ );
189
+
190
+ // --- Scenario 2: Single result auto-escalation (Finding #11 banner notice) ---
191
+ const mockFullEmailPayload = {
192
+ type: "full_email",
193
+ full_email: {
194
+ id: "adeu_12345",
195
+ subject: "Contract Review Required",
196
+ sender_name: "Legal",
197
+ sender_email: "legal@adeu.ai",
198
+ received_datetime: "2026-01-01T12:00:00Z",
199
+ body_html: "<p>Please look at this document.</p>",
200
+ is_thread: false,
201
+ attachments: [],
202
+ },
203
+ };
204
+
205
+ global.fetch = vi.fn().mockResolvedValue({
206
+ ok: true,
207
+ status: 200,
208
+ json: async () => mockFullEmailPayload,
209
+ } as Response);
210
+
211
+ const resEscalation = await search_and_fetch_emails({
212
+ subject: "Contract Review Required",
213
+ });
214
+
215
+ const escalationText = resEscalation.content[0].text;
216
+ expect(
217
+ escalationText.startsWith(
218
+ "_(Search returned exactly one result; auto-fetched full email below.)_",
219
+ ),
220
+ ).toBe(true);
221
+
222
+ // --- Scenario 3: Suggestions for attachments (Finding #9 downstream hint) ---
223
+ const mockAttachmentsPayload = {
224
+ type: "full_email",
225
+ full_email: {
226
+ id: "adeu_12345",
227
+ subject: "Contract Attachment",
228
+ sender_name: "Legal",
229
+ sender_email: "legal@adeu.ai",
230
+ received_datetime: "2026-01-01T12:00:00Z",
231
+ body_html: "<p>Please see attachment.</p>",
232
+ is_thread: false,
233
+ attachments: [
234
+ {
235
+ filename: "draft_contract.docx",
236
+ size_bytes: 1024,
237
+ base64_data: Buffer.from("dummy docx contents").toString("base64"),
238
+ },
239
+ ],
240
+ },
241
+ };
242
+
243
+ global.fetch = vi.fn().mockResolvedValue({
244
+ ok: true,
245
+ status: 200,
246
+ json: async () => mockAttachmentsPayload,
247
+ } as Response);
248
+
249
+ const resAttachments = await search_and_fetch_emails({
250
+ email_id: "adeu_12345",
251
+ });
252
+
253
+ const attachmentsText = resAttachments.content[0].text;
254
+ expect(attachmentsText).toContain(
255
+ "You can now use tools like `read_docx`, `diff_docx_files`, or `finalize_document`",
256
+ );
257
+ });
258
+ });