@adeu/mcp-server 1.9.0 → 1.10.1

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,22 @@ 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(
316
+ "If True, simulates the changes and returns a detailed preview report without modifying any files.",
317
+ ),
300
318
  },
301
319
  },
302
- async ({ original_docx_path, author_name, changes, output_path }) => {
320
+ async ({
321
+ original_docx_path,
322
+ author_name,
323
+ changes,
324
+ output_path,
325
+ dry_run,
326
+ }) => {
303
327
  try {
304
328
  if (!author_name || !author_name.trim())
305
329
  return {
@@ -326,7 +350,7 @@ server.registerTool(
326
350
 
327
351
  let stats;
328
352
  try {
329
- stats = engine.process_batch(changes);
353
+ stats = engine.process_batch(changes, dry_run);
330
354
  } catch (e: any) {
331
355
  if (e instanceof BatchValidationError) {
332
356
  return {
@@ -342,14 +366,12 @@ server.registerTool(
342
366
  throw e;
343
367
  }
344
368
 
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")}`;
369
+ if (!dry_run) {
370
+ const outBuf = await doc.save();
371
+ fs.writeFileSync(outPath, outBuf);
352
372
  }
373
+
374
+ const res = formatBatchResult(stats, outPath, !!dry_run);
353
375
  return { content: [{ type: "text", text: res }] };
354
376
  } catch (e: any) {
355
377
  return {
@@ -533,7 +555,15 @@ server.registerTool(
533
555
  );
534
556
  server.registerTool(
535
557
  "login_to_adeu_cloud",
536
- { description: "Logs the user into the Adeu Cloud backend." },
558
+ {
559
+ description:
560
+ "Logs the user into Adeu Cloud. Opens a browser window for SSO authentication.\n\n" +
561
+ "IMPORTANT — login is user-level, not account-level:\n" +
562
+ "- An Adeu user can have multiple linked provider accounts (Microsoft, Google) and multiple mailboxes (personal + shared/delegated). One linked account is marked primary.\n" +
563
+ "- 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" +
564
+ "- 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" +
565
+ "When the user asks which accounts or mailboxes are available, call `list_available_mailboxes` rather than naming a single account from the login response.",
566
+ },
537
567
  async () => {
538
568
  try {
539
569
  return (await login_to_adeu_cloud()) as any;
@@ -557,7 +587,14 @@ server.registerTool(
557
587
  server.registerTool(
558
588
  "create_email_draft",
559
589
  {
560
- description: "Creates an email draft in the user's native draft box.",
590
+ description:
591
+ "Creates an email draft in the user's native draft box (Outlook Drafts or Gmail Drafts).\n\n" +
592
+ "TWO MODES:\n" +
593
+ "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" +
594
+ "2. New email mode: omit `reply_to_email_id` and pass BOTH `subject` and `to_recipients`.\n\n" +
595
+ "`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" +
596
+ "`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" +
597
+ "`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
598
  inputSchema: {
562
599
  body_markdown: z.string(),
563
600
  reply_to_email_id: z.string().optional(),
@@ -584,7 +621,9 @@ server.registerTool(
584
621
  "list_available_mailboxes",
585
622
  {
586
623
  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.",
624
+ "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" +
625
+ "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" +
626
+ "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.",
588
627
  inputSchema: {},
589
628
  },
590
629
  async () => {
@@ -595,6 +634,51 @@ server.registerTool(
595
634
  }
596
635
  },
597
636
  );
637
+ // --- Formatter for process_document_batch ---
638
+ export function formatBatchResult(
639
+ stats: any,
640
+ outPath: string,
641
+ dry_run: boolean,
642
+ ): string {
643
+ let res = "";
644
+ if (dry_run) {
645
+ res = `Dry-run simulation complete.\n`;
646
+ } else {
647
+ res = `Batch complete. Saved to: ${outPath}\n`;
648
+ }
649
+ res += `Actions: ${stats.actions_applied} applied, ${stats.actions_skipped} skipped.\n`;
650
+ res += `Edits: ${stats.edits_applied} applied, ${stats.edits_skipped} skipped.\n`;
651
+
652
+ if (stats.edits && stats.edits.length > 0) {
653
+ res += "\nDetailed Edit Reports:\n";
654
+ for (let i = 0; i < stats.edits.length; i++) {
655
+ const report = stats.edits[i];
656
+ const status_indicator =
657
+ report.status === "applied" ? "✅ [applied]" : "❌ [failed]";
658
+ res += `Edit ${i + 1} ${status_indicator}:\n`;
659
+ res += ` Target: '${report.target_text}'\n`;
660
+ res += ` New text: '${report.new_text}'\n`;
661
+ if (report.warning) {
662
+ res += ` Warning: ${report.warning}\n`;
663
+ }
664
+ if (report.error) {
665
+ res += ` Error: ${report.error}\n`;
666
+ }
667
+ if (report.critic_markup) {
668
+ res += ` Preview (CriticMarkup): ${report.critic_markup}\n`;
669
+ }
670
+ if (report.clean_text) {
671
+ res += ` Clean text preview: ${report.clean_text}\n`;
672
+ }
673
+ }
674
+ }
675
+
676
+ if (stats.skipped_details && stats.skipped_details.length > 0) {
677
+ res += `\n\nSkipped Details:\n${stats.skipped_details.join("\n")}`;
678
+ }
679
+ return res;
680
+ }
681
+
598
682
  // --- Startup ---
599
683
  async function main() {
600
684
  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) {
@@ -23,11 +24,18 @@ export async function login_to_adeu_cloud(): Promise<ToolResult> {
23
24
  if (!res.ok) throw new Error(`HTTP Error: ${res.status}`);
24
25
 
25
26
  const data: any = await res.json();
27
+ const email = data.email || "Unknown Email";
26
28
  return {
27
29
  content: [
28
30
  {
29
31
  type: "text",
30
- text: `Login successful! Connected to Adeu Cloud as: ${data.email || "Unknown Email"}.`,
32
+ text:
33
+ `Login successful. You are now authenticated to Adeu Cloud as the user ` +
34
+ `who owns the provider account \`${email}\` (the account used for SSO).\n\n` +
35
+ `This single login grants access to ALL of this user's linked provider ` +
36
+ `accounts and ALL of their mailboxes for the duration of this session — ` +
37
+ `not just \`${email}\`. Call \`list_available_mailboxes\` to see every mailbox ` +
38
+ `that can be queried or drafted from.`,
31
39
  },
32
40
  ],
33
41
  };
@@ -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
+ });