@adeu/mcp-server 1.12.0 → 1.13.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
@@ -1,4 +1,3 @@
1
- // FILE: node/packages/mcp-server/src/index.ts
2
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
3
  import { readFileSync, existsSync } from "node:fs";
@@ -41,10 +40,18 @@ function readFileBytesOrThrow(filePath: string): Buffer {
41
40
  } catch (err: any) {
42
41
  if (err.code === "ENOENT") {
43
42
  throw new Error(
44
- `File not found: ${filePath}. Note: If you are running in a sandboxed/containerized environment, ` +
45
- `the host application or MCP server may not have access to your local workspace files. ` +
46
- `You can resolve this by installing Adeu directly inside your sandboxed environment using ` +
47
- `'uv tool install adeu' and executing the commands via the CLI.`
43
+ `File not found: ${filePath}.\n` +
44
+ `If you are running in a sandboxed/containerized environment (such as Claude Desktop or another containerized client), ` +
45
+ `the host application or MCP server may not have direct access to your local workspace files.\n` +
46
+ `You can resolve this by installing and running the local 'adeu' CLI tool directly within your environment.\n` +
47
+ `Here is how the MCP tools map to their CLI equivalents:\n` +
48
+ `- read_docx -> adeu extract ${filePath}\n` +
49
+ `- process_document_batch -> adeu apply ${filePath}\n` +
50
+ `- diff_docx_files -> adeu diff ${filePath} <modified_path>\n` +
51
+ `- accept_all_changes -> adeu accept-all ${filePath}\n\n` +
52
+ `To run the local tool, install it via:\n` +
53
+ ` uv tool install adeu\n` +
54
+ `and run the mapped CLI command directly in your terminal.`
48
55
  );
49
56
  }
50
57
  throw err;
@@ -81,10 +88,15 @@ const DIFF_DOCX_DESC =
81
88
  "Compares two DOCX files and returns a unified diff of their text content. Useful for analyzing differences between versions before editing.";
82
89
 
83
90
  const gitSha = process.env.GIT_SHA || "unknown";
84
- const buildTs = process.env.BUILD_TIMESTAMP || "unknown";
85
91
  const packageVersion = process.env.PACKAGE_VERSION || "unknown";
86
92
  const buildTag = ` [Adeu v${packageVersion}+${gitSha}]`;
87
93
 
94
+ // --- Scope Configuration ---
95
+ const args = process.argv.slice(2);
96
+ const scopeIdx = args.indexOf("--scope");
97
+ const requestedScope = (scopeIdx !== -1 ? args[scopeIdx + 1] : "all").toLowerCase();
98
+ const isDocxOnly = requestedScope === "docx";
99
+
88
100
  // --- Server Setup ---
89
101
  const server = new McpServer({
90
102
  name: "adeu-redlining-service",
@@ -191,7 +203,6 @@ registerAppResource(
191
203
  // ==========================================
192
204
  // 2. UI-ENABLED TOOLS
193
205
  // ==========================================
194
-
195
206
  registerAppTool(
196
207
  server,
197
208
  "read_docx",
@@ -276,66 +287,6 @@ registerAppTool(
276
287
  },
277
288
  );
278
289
 
279
-
280
-
281
- registerAppTool(
282
- server,
283
- "search_and_fetch_emails",
284
- {
285
- title: "Search & Fetch Emails",
286
- description:
287
- "Searches the user's live email inbox via the Adeu cloud backend.\n\n" +
288
- "TWO MODES:\n" +
289
- "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" +
290
- "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" +
291
- "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" +
292
- "EMAIL ID FORMATS (`email_id` parameter accepts any of):\n" +
293
- "- `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" +
294
- "- `adeu_<numeric>` — server-side reference for emails Adeu has previously processed. Portable across machines and sessions for the same authenticated user.\n" +
295
- "- Raw provider ID (Gmail/Outlook native ID) — works if you have it, but you usually won't.\n\n" +
296
- "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" +
297
- "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.",
298
- inputSchema: z.object({
299
- sender: z.string().optional(),
300
- subject: z.string().optional(),
301
- has_attachments: z.boolean().optional(),
302
- attachment_name: z.string().optional(),
303
- is_unread: z.boolean().optional(),
304
- days_ago: z.number().optional(),
305
- folder: z.enum(["inbox", "sent", "all"]).optional(),
306
- limit: z.number().default(10),
307
- offset: z.number().default(0),
308
- email_id: z.string().optional(),
309
- working_directory: z.string().optional(),
310
- mailbox_address: z
311
- .string()
312
- .optional()
313
- .describe("Optional target mailbox email address to search within."),
314
- task_id: z
315
- .string()
316
- .optional()
317
- .describe("If resuming a pending check, provide the task ID here."),
318
- max_attachment_size_mb: z
319
- .number()
320
- .optional()
321
- .describe(
322
- "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.",
323
- ),
324
- }),
325
- _meta: { ui: { resourceUri: EMAIL_UI_URI } },
326
- },
327
- async (args) => {
328
- try {
329
- return (await search_and_fetch_emails(args)) as any;
330
- } catch (e: any) {
331
- return {
332
- isError: true,
333
- content: [{ type: "text", text: e.message }],
334
- };
335
- }
336
- },
337
- );
338
-
339
290
  // ==========================================
340
291
  // 3. HEADLESS TOOLS (No UI)
341
292
  // ==========================================
@@ -600,6 +551,66 @@ server.registerTool(
600
551
  }
601
552
  },
602
553
  );
554
+
555
+ if (!isDocxOnly) {
556
+ registerAppTool(
557
+ server,
558
+ "search_and_fetch_emails",
559
+ {
560
+ title: "Search & Fetch Emails",
561
+ description:
562
+ "Searches the user's live email inbox via the Adeu cloud backend.\n\n" +
563
+ "TWO MODES:\n" +
564
+ "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" +
565
+ "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" +
566
+ "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" +
567
+ "EMAIL ID FORMATS (`email_id` parameter accepts any of):\n" +
568
+ "- `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" +
569
+ "- `adeu_<numeric>` — server-side reference for emails Adeu has previously processed. Portable across machines and sessions for the same authenticated user.\n" +
570
+ "- Raw provider ID (Gmail/Outlook native ID) — works if you have it, but you usually won't.\n\n" +
571
+ "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" +
572
+ "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.",
573
+ inputSchema: z.object({
574
+ sender: z.string().optional(),
575
+ subject: z.string().optional(),
576
+ has_attachments: z.boolean().optional(),
577
+ attachment_name: z.string().optional(),
578
+ is_unread: z.boolean().optional(),
579
+ days_ago: z.number().optional(),
580
+ folder: z.enum(["inbox", "sent", "all"]).optional(),
581
+ limit: z.number().default(10),
582
+ offset: z.number().default(0),
583
+ email_id: z.string().optional(),
584
+ working_directory: z.string().optional(),
585
+ mailbox_address: z
586
+ .string()
587
+ .optional()
588
+ .describe("Optional target mailbox email address to search within."),
589
+ task_id: z
590
+ .string()
591
+ .optional()
592
+ .describe("If resuming a pending check, provide the task ID here."),
593
+ max_attachment_size_mb: z
594
+ .number()
595
+ .optional()
596
+ .describe(
597
+ "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.",
598
+ ),
599
+ }),
600
+ _meta: { ui: { resourceUri: EMAIL_UI_URI } },
601
+ },
602
+ async (args) => {
603
+ try {
604
+ return (await search_and_fetch_emails(args)) as any;
605
+ } catch (e: any) {
606
+ return {
607
+ isError: true,
608
+ content: [{ type: "text", text: e.message }],
609
+ };
610
+ }
611
+ },
612
+ );
613
+
603
614
  server.registerTool(
604
615
  "login_to_adeu_cloud",
605
616
  {
@@ -681,6 +692,8 @@ server.registerTool(
681
692
  }
682
693
  },
683
694
  );
695
+ }
696
+
684
697
  // --- Formatter for process_document_batch ---
685
698
  export function formatBatchResult(
686
699
  stats: any,
@@ -266,20 +266,76 @@ describe("Node Email Tools Finding #2 and Finding #6 tests", () => {
266
266
  vi.useRealTimers();
267
267
  });
268
268
 
269
- it("should handle async task initiation upon searching", async () => {
270
- global.fetch = vi.fn().mockResolvedValue({
271
- ok: true,
272
- status: 202,
273
- json: async () => ({
274
- status: "pending",
275
- task_id: "email_task_typescript_123",
276
- message: "Queued"
277
- }),
278
- } as Response);
269
+ it("should handle async task initiation upon searching and resolve when the task completes successfully", async () => {
270
+ let callCount = 0;
271
+ global.fetch = vi.fn().mockImplementation(async () => {
272
+ callCount++;
273
+ if (callCount === 1) {
274
+ return {
275
+ ok: true,
276
+ status: 202,
277
+ json: async () => ({
278
+ status: "pending",
279
+ task_id: "email_task_typescript_123",
280
+ message: "Queued",
281
+ }),
282
+ } as Response;
283
+ }
284
+ return {
285
+ ok: true,
286
+ status: 200,
287
+ json: async () => ({
288
+ status: "COMPLETED",
289
+ type: "previews",
290
+ previews: [],
291
+ }),
292
+ } as Response;
293
+ });
294
+
295
+ const promise = search_and_fetch_emails({ subject: "heavy search" });
296
+ promise.catch(() => {});
297
+ await vi.runAllTimersAsync();
279
298
 
280
- const result = await search_and_fetch_emails({ subject: "heavy search" });
299
+ const result = await promise;
281
300
 
282
- expect(result.content[0].text).toContain("Email processing task started successfully");
301
+ expect(callCount).toBe(2);
302
+ expect(result.content[0].text).toContain("No emails found matching your search criteria.");
303
+ });
304
+
305
+ it("should handle async task initiation upon searching and return pending status on polling timeout (50s)", async () => {
306
+ let callCount = 0;
307
+ global.fetch = vi.fn().mockImplementation(async () => {
308
+ callCount++;
309
+ if (callCount === 1) {
310
+ return {
311
+ ok: true,
312
+ status: 202,
313
+ json: async () => ({
314
+ status: "pending",
315
+ task_id: "email_task_typescript_123",
316
+ message: "Queued",
317
+ }),
318
+ } as Response;
319
+ }
320
+ return {
321
+ ok: true,
322
+ status: 200,
323
+ json: async () => ({ status: "PENDING" }),
324
+ } as Response;
325
+ });
326
+
327
+ const promise = search_and_fetch_emails({ subject: "heavy search" });
328
+ promise.catch(() => {});
329
+
330
+ for (let i = 0; i < 10; i++) {
331
+ await vi.advanceTimersByTimeAsync(5000);
332
+ }
333
+ await vi.runAllTimersAsync();
334
+
335
+ const result = await promise;
336
+
337
+ expect(callCount).toBe(11);
338
+ expect(result.content[0].text).toContain("is still processing");
283
339
  expect(result.content[0].text).toContain("task_id=email_task_typescript_123");
284
340
  expect(result.structuredContent?.status).toBe("pending");
285
341
  expect(result.structuredContent?.task_id).toBe("email_task_typescript_123");
@@ -302,6 +302,55 @@ function getUniqueFilepath(saveDir: string, filename: string): string {
302
302
  // mean the same logical attachment.
303
303
  return join(saveDir, filename);
304
304
  }
305
+ async function pollEmailTask(taskId: string, apiKey: string): Promise<any> {
306
+ const pollUrl = `${BACKEND_URL}/api/v1/emails/tasks/${taskId}`;
307
+
308
+ for (let attempt = 0; attempt < 10; attempt++) {
309
+ let res: Response;
310
+ try {
311
+ res = await fetch(pollUrl, {
312
+ headers: {
313
+ Authorization: `Bearer ${apiKey}`,
314
+ Accept: "application/json",
315
+ },
316
+ signal: AbortSignal.timeout(15_000),
317
+ });
318
+ } catch (err) {
319
+ if (isTimeoutError(err)) {
320
+ throw new Error("Checking task status timed out.");
321
+ }
322
+ throw err;
323
+ }
324
+
325
+ if (res.status === 401) {
326
+ DesktopAuthManager.clearApiKey();
327
+ throw new Error(
328
+ "Authentication expired. Please call `login_to_adeu_cloud` to re-authenticate.",
329
+ );
330
+ }
331
+ if (!res.ok) {
332
+ throw new Error(formatBackendError(res.status, await res.text()));
333
+ }
334
+
335
+ const taskData: any = await res.json();
336
+ const status = taskData.status;
337
+
338
+ if (status === "COMPLETED") {
339
+ return taskData;
340
+ }
341
+
342
+ if (status === "FAILED") {
343
+ const errorMsg = taskData.error || "Unknown internal error";
344
+ throw new Error(`Validation task failed on the server: ${errorMsg}`);
345
+ }
346
+
347
+ // Wait 5 seconds before next poll
348
+ await new Promise((resolve) => setTimeout(resolve, 5000));
349
+ }
350
+
351
+ return null;
352
+ }
353
+
305
354
  export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
306
355
  const apiKey = await getCloudAuthToken();
307
356
  const maxAttachmentSizeMb: number =
@@ -316,52 +365,7 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
316
365
  // ==========================================
317
366
  // PHASE 2: POLL (Wait for completion)
318
367
  // ==========================================
319
- const pollUrl = `${BACKEND_URL}/api/v1/emails/tasks/${args.task_id}`;
320
- let completedData: any = null;
321
-
322
- for (let attempt = 0; attempt < 10; attempt++) {
323
- let res: Response;
324
- try {
325
- res = await fetch(pollUrl, {
326
- headers: {
327
- Authorization: `Bearer ${apiKey}`,
328
- Accept: "application/json",
329
- },
330
- signal: AbortSignal.timeout(15_000),
331
- });
332
- } catch (err) {
333
- if (isTimeoutError(err)) {
334
- throw new Error("Checking task status timed out.");
335
- }
336
- throw err;
337
- }
338
-
339
- if (res.status === 401) {
340
- DesktopAuthManager.clearApiKey();
341
- throw new Error(
342
- "Authentication expired. Please call `login_to_adeu_cloud` to re-authenticate.",
343
- );
344
- }
345
- if (!res.ok) {
346
- throw new Error(formatBackendError(res.status, await res.text()));
347
- }
348
-
349
- const taskData: any = await res.json();
350
- const status = taskData.status;
351
-
352
- if (status === "COMPLETED") {
353
- completedData = taskData;
354
- break;
355
- }
356
-
357
- if (status === "FAILED") {
358
- const errorMsg = taskData.error || "Unknown internal error";
359
- throw new Error(`Validation task failed on the server: ${errorMsg}`);
360
- }
361
-
362
- // Wait 5 seconds before next poll
363
- await new Promise((resolve) => setTimeout(resolve, 5000));
364
- }
368
+ const completedData = await pollEmailTask(args.task_id, apiKey);
365
369
 
366
370
  if (!completedData) {
367
371
  const msg = `Task ${args.task_id} is still processing. Please call \`search_and_fetch_emails\` again with task_id=${args.task_id}.`;
@@ -446,15 +450,20 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
446
450
 
447
451
  if (res.status === 202 || (data && (data.status === "pending" || data.task_id) && data.type === undefined)) {
448
452
  const newTaskId = data.task_id;
449
- const msg = `Email processing task started successfully. Task ID: ${newTaskId}. Please call \`search_and_fetch_emails\` again immediately with task_id=${newTaskId} to monitor the progress.`;
450
- return {
451
- content: [{ type: "text", text: msg }],
452
- structuredContent: {
453
- status: "pending",
454
- task_id: String(newTaskId),
455
- message: msg,
456
- },
457
- };
453
+ const completedData = await pollEmailTask(String(newTaskId), apiKey);
454
+
455
+ if (!completedData) {
456
+ const msg = `Task ${newTaskId} is still processing. Please call \`search_and_fetch_emails\` again immediately with task_id=${newTaskId} to monitor the progress.`;
457
+ return {
458
+ content: [{ type: "text", text: msg }],
459
+ structuredContent: {
460
+ status: "pending",
461
+ task_id: String(newTaskId),
462
+ message: msg,
463
+ },
464
+ };
465
+ }
466
+ data = completedData;
458
467
  }
459
468
  }
460
469