@adeu/mcp-server 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,11 +1,15 @@
1
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
1
+ // FILE: node/packages/mcp-server/src/index.ts
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { readFileSync, existsSync } from "node:fs";
5
+ import { basename, resolve, extname, dirname, join } from "node:path";
6
+ import { z } from "zod";
3
7
  import {
4
- CallToolRequestSchema,
5
- ListToolsRequestSchema,
6
- } from "@modelcontextprotocol/sdk/types.js";
7
- import { readFileSync } from "node:fs";
8
- import { basename, resolve, extname, dirname } from "node:path";
8
+ registerAppTool,
9
+ registerAppResource,
10
+ RESOURCE_MIME_TYPE,
11
+ } from "@modelcontextprotocol/ext-apps/server";
12
+ import fs from "node:fs";
9
13
  import {
10
14
  identifyEngine,
11
15
  extractTextFromBuffer,
@@ -15,13 +19,21 @@ import {
15
19
  create_word_patch_diff,
16
20
  finalize_document,
17
21
  } from "@adeu/core";
22
+
18
23
  import {
19
24
  build_paginated_response,
20
25
  build_outline_response,
21
26
  build_appendix_response,
22
27
  } from "./response-builders.js";
28
+
23
29
  import { login_to_adeu_cloud, logout_of_adeu_cloud } from "./tools/auth.js";
24
- import { search_and_fetch_emails, create_email_draft } from "./tools/email.js";
30
+ import {
31
+ search_and_fetch_emails,
32
+ create_email_draft,
33
+ list_available_mailboxes,
34
+ } from "./tools/email.js";
35
+ import { MARKDOWN_UI_URI, EMAIL_UI_URI } from "./shared.js";
36
+
25
37
  function readFileBytesOrThrow(filePath: string): Buffer {
26
38
  try {
27
39
  return readFileSync(filePath);
@@ -32,7 +44,23 @@ function readFileBytesOrThrow(filePath: string): Buffer {
32
44
  throw err;
33
45
  }
34
46
  }
35
- // --- Tool Description Constants (Parity with Python) ---
47
+
48
+ // --- Asset Loaders for UI ---
49
+ const DIST_DIR = import.meta.dirname;
50
+
51
+ function getAssetContent(
52
+ folder: "templates" | "assets",
53
+ filename: string,
54
+ fallbackMessage: string,
55
+ ): string {
56
+ const filePath = join(DIST_DIR, folder, filename);
57
+ if (existsSync(filePath)) {
58
+ return readFileSync(filePath, "utf-8");
59
+ }
60
+ return fallbackMessage;
61
+ }
62
+
63
+ // --- Tool Description Constants ---
36
64
  const READ_DOCX_COMMON_DESC =
37
65
  "Reads a DOCX file. Returns text with inline CriticMarkup for Tracked Changes and Comments: {++inserted++}, {--deleted--}, {==highlighted==}{>>comment<<}. Set clean_view=True for the finalized 'Accepted' text without markup.\n\n";
38
66
  const READ_DOCX_TAIL =
@@ -47,450 +75,592 @@ const DIFF_DOCX_DESC =
47
75
  "Compares two DOCX files and returns a unified diff of their text content. Useful for analyzing differences between versions before editing.";
48
76
 
49
77
  // --- Server Setup ---
50
- const server = new Server(
51
- {
52
- name: "adeu-redlining-service",
53
- version: "1.0.0",
54
- },
55
- {
56
- capabilities: {
57
- tools: {},
58
- },
78
+ const server = new McpServer({
79
+ name: "adeu-redlining-service",
80
+ version: "1.0.0",
81
+ });
82
+
83
+ // Common CSP allowing Google Fonts used by Adeu UI templates
84
+ const UI_CSP = {
85
+ connectDomains: ["https://fonts.googleapis.com", "https://fonts.gstatic.com"],
86
+ resourceDomains: [
87
+ "https://fonts.googleapis.com",
88
+ "https://fonts.gstatic.com",
89
+ ],
90
+ };
91
+
92
+ // ==========================================
93
+ // 1. UI RESOURCES
94
+ // ==========================================
95
+
96
+ registerAppResource(
97
+ server,
98
+ MARKDOWN_UI_URI,
99
+ MARKDOWN_UI_URI,
100
+ { mimeType: RESOURCE_MIME_TYPE, description: "Adeu Markdown Viewer UI" },
101
+ async () => {
102
+ let html = getAssetContent(
103
+ "templates",
104
+ "markdown_ui.html",
105
+ "<html><body>UI Template Not Found</body></html>",
106
+ );
107
+ const markedJs = getAssetContent(
108
+ "assets",
109
+ "marked.min.js",
110
+ "window.__MARKED_ERROR = 'marked.min.js not found';",
111
+ );
112
+ const svg = getAssetContent("assets", "adeu.svg", "");
113
+
114
+ html = html
115
+ .replace("[[marked_js_code | safe]]", markedJs)
116
+ .replace("[[ adeu_svg_code ]]", svg);
117
+
118
+ return {
119
+ contents: [
120
+ {
121
+ uri: MARKDOWN_UI_URI,
122
+ mimeType: RESOURCE_MIME_TYPE,
123
+ text: html,
124
+ _meta: { ui: { csp: UI_CSP } },
125
+ },
126
+ ],
127
+ };
59
128
  },
60
129
  );
61
130
 
62
- // --- Tool Registration ---
63
- server.setRequestHandler(ListToolsRequestSchema, async () => {
64
- return {
65
- tools: [
66
- {
67
- name: "read_docx",
68
- description: READ_DOCX_COMMON_DESC + READ_DOCX_TAIL,
69
- inputSchema: {
70
- type: "object",
71
- properties: {
72
- file_path: {
73
- type: "string",
74
- description: "Absolute path to the DOCX file.",
75
- },
76
- clean_view: {
77
- type: "boolean",
78
- description:
79
- "If False (default), returns the 'Raw' text with inline CriticMarkup. If True, returns 'Accepted' text.",
80
- default: false,
81
- },
82
- mode: {
83
- type: "string",
84
- enum: ["full", "outline", "appendix"],
85
- description:
86
- "'full' returns body content. 'outline' returns a structural heading map. 'appendix' returns defined terms.",
87
- default: "full",
88
- },
89
- page: {
90
- type: "number",
91
- description:
92
- "Page number (1-indexed) for mode='full'. Defaults to 1.",
93
- default: 1,
94
- },
95
- outline_max_level: {
96
- type: "number",
97
- description: "For mode='outline' only: cap on heading depth.",
98
- default: 2,
99
- },
100
- outline_verbose: {
101
- type: "boolean",
102
- description: "For mode='outline' only: includes metadata.",
103
- default: false,
104
- },
105
- },
106
- required: ["file_path"],
107
- },
108
- },
109
- {
110
- name: "process_document_batch",
111
- description: PROCESS_BATCH_COMMON_DESC + PROCESS_BATCH_OPERATIONS_DESC,
112
- inputSchema: {
113
- type: "object",
114
- properties: {
115
- original_docx_path: {
116
- type: "string",
117
- description: "Absolute path to the source file.",
118
- },
119
- author_name: {
120
- type: "string",
121
- description:
122
- "Name to appear in Track Changes (e.g., 'Reviewer AI').",
123
- },
124
- changes: {
125
- type: "array",
126
- description:
127
- "List of changes to apply. Each change must specify 'type'.",
128
- items: { type: "object" },
129
- },
130
- output_path: {
131
- type: "string",
132
- description: "Optional output path.",
133
- },
134
- },
135
- required: ["original_docx_path", "author_name", "changes"],
131
+ registerAppResource(
132
+ server,
133
+ EMAIL_UI_URI,
134
+ EMAIL_UI_URI,
135
+ { mimeType: RESOURCE_MIME_TYPE, description: "Adeu Email Viewer UI" },
136
+ async () => {
137
+ let html = getAssetContent(
138
+ "templates",
139
+ "email_ui.html",
140
+ "<html><body>UI Template Not Found</body></html>",
141
+ );
142
+ const svg = getAssetContent("assets", "adeu.svg", "");
143
+
144
+ html = html.replace("[[ adeu_svg_code ]]", svg);
145
+
146
+ return {
147
+ contents: [
148
+ {
149
+ uri: EMAIL_UI_URI,
150
+ mimeType: RESOURCE_MIME_TYPE,
151
+ text: html,
152
+ _meta: { ui: { csp: UI_CSP } },
136
153
  },
137
- },
138
- {
139
- name: "accept_all_changes",
140
- description:
141
- "Accepts all tracked changes and removes all comments in a single operation, producing a finalized clean document. Use this when a document review is entirely complete and you want to clear all redlines.",
142
- inputSchema: {
143
- type: "object",
144
- properties: {
145
- docx_path: {
146
- type: "string",
147
- description: "Absolute path to the DOCX file.",
148
- },
149
- output_path: {
150
- type: "string",
151
- description: "Optional output path.",
152
- },
153
- },
154
- required: ["docx_path"],
155
- },
156
- },
157
- {
158
- name: "diff_docx_files",
159
- description: DIFF_DOCX_DESC,
160
- inputSchema: {
161
- type: "object",
162
- properties: {
163
- original_path: {
164
- type: "string",
165
- description: "Absolute path to the baseline DOCX file.",
166
- },
167
- modified_path: {
168
- type: "string",
169
- description: "Absolute path to the modified DOCX file.",
170
- },
171
- compare_clean: {
172
- type: "boolean",
173
- description:
174
- "If True, compares 'Accepted' state. If False, compares raw text.",
175
- default: true,
176
- },
177
- },
178
- required: ["original_path", "modified_path"],
179
- },
180
- },
181
- {
182
- name: "finalize_document",
183
- description:
184
- "Prepares a document for external distribution or e-signature. This tool combines metadata sanitization, document locking (protection), and markup resolution into a single step. NOTE: PDF export and AES encryption are disabled in this environment.",
185
- inputSchema: {
186
- type: "object",
187
- properties: {
188
- file_path: {
189
- type: "string",
190
- description: "Absolute path to the DOCX file.",
191
- },
192
- output_path: {
193
- type: "string",
194
- description: "Optional output path.",
195
- },
196
- sanitize_mode: {
197
- type: "string",
198
- enum: ["full", "keep-markup"],
199
- description:
200
- "full removes all markup, keep-markup redacts metadata but keeps comments/redlines.",
201
- },
202
- accept_all: {
203
- type: "boolean",
204
- description:
205
- "If true, auto-accepts all unresolved track changes before finalizing.",
206
- },
207
- protection_mode: {
208
- type: "string",
209
- enum: ["read_only", "encrypt"],
210
- description:
211
- "Native OOXML document locking. encrypt falls back to read_only in this environment.",
212
- },
213
- password: {
214
- type: "string",
215
- description: "Ignored in this environment.",
216
- },
217
- author: {
218
- type: "string",
219
- description:
220
- "Replace all remaining markup authorship with this name.",
221
- },
222
- export_pdf: {
223
- type: "boolean",
224
- description: "Ignored in this environment.",
225
- },
226
- },
227
- required: ["file_path"],
228
- },
229
- },
230
- {
231
- name: "login_to_adeu_cloud",
232
- description:
233
- "Logs the user into the Adeu Cloud backend. Securely opens a browser window for authentication.",
234
- inputSchema: { type: "object", properties: {} },
235
- },
236
- {
237
- name: "logout_of_adeu_cloud",
238
- description:
239
- "Logs out of the Adeu Cloud backend by clearing the local API key.",
240
- inputSchema: { type: "object", properties: {} },
241
- },
242
- {
243
- name: "search_and_fetch_emails",
244
- description:
245
- "Searches the user's live email inbox. By default, searches only the Inbox folder. Returns a list of lightweight previews. Call again with `email_id` to fetch the full body and download attachments.",
246
- inputSchema: {
247
- type: "object",
248
- properties: {
249
- sender: { type: "string" },
250
- subject: { type: "string" },
251
- has_attachments: { type: "boolean" },
252
- attachment_name: { type: "string" },
253
- is_unread: { type: "boolean" },
254
- days_ago: { type: "number" },
255
- folder: { type: "string", enum: ["inbox", "sent", "all"] },
256
- limit: { type: "number", default: 10 },
257
- offset: { type: "number", default: 0 },
258
- email_id: { type: "string" },
259
- working_directory: { type: "string" },
260
- },
261
- },
262
- },
263
- {
264
- name: "create_email_draft",
265
- description:
266
- "Creates an email draft in the user's native draft box. Provide `reply_to_email_id` to reply, or `subject` and `to_recipients` for a new email.",
267
- inputSchema: {
268
- type: "object",
269
- properties: {
270
- body_markdown: { type: "string" },
271
- reply_to_email_id: { type: "string" },
272
- subject: { type: "string" },
273
- to_recipients: { type: "array", items: { type: "string" } },
274
- attachment_paths: { type: "array", items: { type: "string" } },
154
+ ],
155
+ };
156
+ },
157
+ );
158
+
159
+ // ==========================================
160
+ // 2. UI-ENABLED TOOLS
161
+ // ==========================================
162
+
163
+ registerAppTool(
164
+ server,
165
+ "read_docx",
166
+ {
167
+ title: "Read DOCX",
168
+ description: READ_DOCX_COMMON_DESC + READ_DOCX_TAIL,
169
+ inputSchema: z.object({
170
+ file_path: z.string().describe("Absolute path to the DOCX file."),
171
+ clean_view: z
172
+ .boolean()
173
+ .default(false)
174
+ .describe(
175
+ "If False (default), returns the 'Raw' text with inline CriticMarkup. If True, returns 'Accepted' text.",
176
+ ),
177
+ mode: z
178
+ .enum(["full", "outline", "appendix"])
179
+ .default("full")
180
+ .describe(
181
+ "'full' returns body content. 'outline' returns a structural heading map. 'appendix' returns defined terms.",
182
+ ),
183
+ page: z
184
+ .number()
185
+ .default(1)
186
+ .describe("Page number (1-indexed) for mode='full'. Defaults to 1."),
187
+ outline_max_level: z
188
+ .number()
189
+ .default(2)
190
+ .describe("For mode='outline' only: cap on heading depth."),
191
+ outline_verbose: z
192
+ .boolean()
193
+ .default(false)
194
+ .describe("For mode='outline' only: includes metadata."),
195
+ }),
196
+ _meta: { ui: { resourceUri: MARKDOWN_UI_URI } },
197
+ },
198
+ async ({
199
+ file_path,
200
+ clean_view,
201
+ mode,
202
+ page,
203
+ outline_max_level,
204
+ outline_verbose,
205
+ }) => {
206
+ try {
207
+ const buf = readFileBytesOrThrow(file_path);
208
+ const text = await extractTextFromBuffer(buf, clean_view);
209
+
210
+ if (mode === "outline") {
211
+ const doc = await DocumentObject.load(buf);
212
+ return build_outline_response(
213
+ doc,
214
+ text,
215
+ file_path,
216
+ outline_max_level,
217
+ outline_verbose,
218
+ ) as any;
219
+ }
220
+ if (mode === "appendix") {
221
+ return build_appendix_response(text, page, file_path) as any;
222
+ }
223
+ return build_paginated_response(text, page, file_path) as any;
224
+ } catch (e: any) {
225
+ return {
226
+ isError: true,
227
+ content: [
228
+ {
229
+ type: "text",
230
+ text: `Error executing tool read_docx: ${e.message}`,
275
231
  },
276
- required: ["body_markdown"],
277
- },
278
- },
279
- ],
280
- };
281
- });
232
+ ],
233
+ };
234
+ }
235
+ },
236
+ );
282
237
 
283
- // --- Tool Execution ---
284
- server.setRequestHandler(
285
- CallToolRequestSchema,
286
- async (request): Promise<any> => {
287
- const { name, arguments: args } = request.params;
238
+ registerAppTool(
239
+ server,
240
+ "search_and_fetch_emails",
241
+ {
242
+ title: "Search & Fetch Emails",
243
+ description:
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.",
255
+ inputSchema: z.object({
256
+ sender: z.string().optional(),
257
+ subject: z.string().optional(),
258
+ has_attachments: z.boolean().optional(),
259
+ attachment_name: z.string().optional(),
260
+ is_unread: z.boolean().optional(),
261
+ days_ago: z.number().optional(),
262
+ folder: z.enum(["inbox", "sent", "all"]).optional(),
263
+ limit: z.number().default(10),
264
+ offset: z.number().default(0),
265
+ email_id: z.string().optional(),
266
+ working_directory: z.string().optional(),
267
+ mailbox_address: z
268
+ .string()
269
+ .optional()
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
+ ),
277
+ }),
278
+ _meta: { ui: { resourceUri: EMAIL_UI_URI } },
279
+ },
280
+ async (args) => {
281
+ try {
282
+ return (await search_and_fetch_emails(args)) as any;
283
+ } catch (e: any) {
284
+ return {
285
+ isError: true,
286
+ content: [{ type: "text", text: e.message }],
287
+ };
288
+ }
289
+ },
290
+ );
291
+
292
+ // ==========================================
293
+ // 3. HEADLESS TOOLS (No UI)
294
+ // ==========================================
288
295
 
296
+ server.registerTool(
297
+ "process_document_batch",
298
+ {
299
+ description: PROCESS_BATCH_COMMON_DESC + PROCESS_BATCH_OPERATIONS_DESC,
300
+ inputSchema: {
301
+ original_docx_path: z
302
+ .string()
303
+ .describe("Absolute path to the source file."),
304
+ author_name: z
305
+ .string()
306
+ .describe("Name to appear in Track Changes (e.g., 'Reviewer AI')."),
307
+ changes: z
308
+ .array(z.any())
309
+ .describe("List of changes to apply. Each change must specify 'type'."),
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."),
316
+ },
317
+ },
318
+ async ({ original_docx_path, author_name, changes, output_path, dry_run }) => {
289
319
  try {
290
- if (name === "read_docx") {
291
- const filePath = args?.file_path as string;
292
- const cleanView = (args?.clean_view as boolean) ?? false;
293
- const mode = (args?.mode as string) ?? "full";
294
- const page = (args?.page as number) ?? 1;
295
- const outline_max_level = (args?.outline_max_level as number) ?? 2;
296
- const outline_verbose = (args?.outline_verbose as boolean) ?? false;
297
-
298
- const buf = readFileBytesOrThrow(filePath);
299
- const text = await extractTextFromBuffer(buf, cleanView);
300
-
301
- if (mode === "outline") {
302
- const doc = await DocumentObject.load(buf);
303
- return build_outline_response(
304
- doc,
305
- text,
306
- filePath,
307
- outline_max_level,
308
- outline_verbose,
309
- );
310
- }
311
- if (mode === "appendix") {
312
- return build_appendix_response(text, page, filePath);
313
- }
314
- return build_paginated_response(text, page, filePath);
320
+ if (!author_name || !author_name.trim())
321
+ return {
322
+ content: [
323
+ { type: "text", text: "Error: author_name cannot be empty." },
324
+ ],
325
+ };
326
+ if (!changes || changes.length === 0)
327
+ return {
328
+ content: [{ type: "text", text: "Error: No changes provided." }],
329
+ };
330
+
331
+ let outPath = output_path;
332
+ if (!outPath) {
333
+ const ext = extname(original_docx_path);
334
+ const base = basename(original_docx_path, ext);
335
+ const dir = dirname(original_docx_path);
336
+ outPath = resolve(dir, `${base}_processed${ext}`);
315
337
  }
316
- if (name === "process_document_batch") {
317
- const origPath = args?.original_docx_path as string;
318
- const authorName = args?.author_name as string;
319
- const changes = args?.changes as any[];
320
- let outPath = args?.output_path as string;
321
338
 
322
- if (!authorName || !authorName.trim()) {
339
+ const buf = readFileBytesOrThrow(original_docx_path);
340
+ const doc = await DocumentObject.load(buf);
341
+ const engine = new RedlineEngine(doc, author_name);
342
+
343
+ let stats;
344
+ try {
345
+ stats = engine.process_batch(changes, dry_run);
346
+ } catch (e: any) {
347
+ if (e instanceof BatchValidationError) {
323
348
  return {
349
+ isError: true,
324
350
  content: [
325
- { type: "text", text: "Error: author_name cannot be empty." },
351
+ {
352
+ type: "text",
353
+ text: `Batch rejected. Some edits failed validation:\n\n${e.errors.join("\n\n")}`,
354
+ },
326
355
  ],
327
356
  };
328
357
  }
358
+ throw e;
359
+ }
329
360
 
330
- if (!changes || changes.length === 0) {
331
- return {
332
- content: [{ type: "text", text: "Error: No changes provided." }],
333
- };
334
- }
335
- if (!outPath) {
336
- const ext = extname(origPath);
337
- const base = basename(origPath, ext);
338
- const dir = dirname(origPath);
339
- outPath = resolve(dir, `${base}_processed${ext}`);
340
- }
341
-
342
- const buf = readFileBytesOrThrow(origPath);
343
- const doc = await DocumentObject.load(buf);
344
- const engine = new RedlineEngine(doc, authorName);
345
-
346
- let stats;
347
- try {
348
- stats = engine.process_batch(changes);
349
- } catch (e) {
350
- if (e instanceof BatchValidationError) {
351
- return {
352
- content: [
353
- {
354
- type: "text",
355
- text: `Batch rejected. Some edits failed validation:\n\n${(e as BatchValidationError).errors.join("\n\n")}`,
356
- },
357
- ],
358
- isError: true,
359
- };
360
- }
361
- throw e;
362
- }
363
-
361
+ if (!dry_run) {
364
362
  const outBuf = await doc.save();
365
- // Using dynamic import of fs/promises or just sync write
366
- const fs = await import("node:fs");
367
363
  fs.writeFileSync(outPath, outBuf);
364
+ }
368
365
 
369
- 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.`;
370
- if (stats.skipped_details?.length > 0) {
371
- res += `\n\nSkipped Details:\n${stats.skipped_details.join("\n")}`;
372
- }
366
+ const res = formatBatchResult(stats, outPath, !!dry_run);
367
+ return { content: [{ type: "text", text: res }] };
368
+ } catch (e: any) {
369
+ return {
370
+ isError: true,
371
+ content: [{ type: "text", text: `Error: ${e.message}` }],
372
+ };
373
+ }
374
+ },
375
+ );
373
376
 
374
- return {
375
- content: [{ type: "text", text: res }],
376
- };
377
+ server.registerTool(
378
+ "accept_all_changes",
379
+ {
380
+ description:
381
+ "Accepts all tracked changes and removes all comments in a single operation.",
382
+ inputSchema: {
383
+ docx_path: z.string().describe("Absolute path to the DOCX file."),
384
+ output_path: z.string().optional().describe("Optional output path."),
385
+ },
386
+ },
387
+ async ({ docx_path, output_path }) => {
388
+ try {
389
+ let outPath = output_path;
390
+ if (!outPath) {
391
+ const ext = extname(docx_path);
392
+ const base = basename(docx_path, ext);
393
+ const dir = dirname(docx_path);
394
+ outPath = resolve(dir, `${base}_clean${ext}`);
377
395
  }
378
396
 
379
- if (name === "accept_all_changes") {
380
- const docxPath = args?.docx_path as string;
381
- let outPath = args?.output_path as string;
397
+ const buf = readFileBytesOrThrow(docx_path);
398
+ const doc = await DocumentObject.load(buf);
399
+ const engine = new RedlineEngine(doc);
382
400
 
383
- if (!outPath) {
384
- const ext = extname(docxPath);
385
- const base = basename(docxPath, ext);
386
- const dir = dirname(docxPath);
387
- outPath = resolve(dir, `${base}_clean${ext}`);
388
- }
401
+ engine.accept_all_revisions();
389
402
 
390
- const buf = readFileBytesOrThrow(docxPath);
391
- const doc = await DocumentObject.load(buf);
392
- const engine = new RedlineEngine(doc);
403
+ const outBuf = await doc.save();
393
404
 
394
- // We implement the public facing accept_all wrapper from python
395
- engine.accept_all_revisions();
405
+ fs.writeFileSync(outPath, outBuf);
396
406
 
397
- const outBuf = await doc.save();
398
- const fs = await import("node:fs");
399
- fs.writeFileSync(outPath, outBuf);
407
+ return {
408
+ content: [
409
+ { type: "text", text: `Accepted all changes. Saved to: ${outPath}` },
410
+ ],
411
+ };
412
+ } catch (e: any) {
413
+ return {
414
+ isError: true,
415
+ content: [{ type: "text", text: `Error: ${e.message}` }],
416
+ };
417
+ }
418
+ },
419
+ );
400
420
 
401
- return {
402
- content: [
403
- {
404
- type: "text",
405
- text: `Accepted all changes. Saved to: ${outPath}`,
406
- },
407
- ],
408
- };
409
- }
410
- if (name === "diff_docx_files") {
411
- const origPath = args?.original_path as string;
412
- const modPath = args?.modified_path as string;
413
- const compareClean = (args?.compare_clean as boolean) ?? true;
414
- const origBuf = readFileBytesOrThrow(origPath);
415
- const modBuf = readFileBytesOrThrow(modPath);
416
-
417
- // Pass compareClean flag into extraction
418
- const origText = await extractTextFromBuffer(origBuf, compareClean);
419
- const modText = await extractTextFromBuffer(modBuf, compareClean);
420
-
421
- const diff = create_word_patch_diff(
422
- origText,
423
- modText,
424
- basename(origPath),
425
- basename(modPath),
426
- );
421
+ server.registerTool(
422
+ "diff_docx_files",
423
+ {
424
+ description: DIFF_DOCX_DESC,
425
+ inputSchema: {
426
+ original_path: z
427
+ .string()
428
+ .describe("Absolute path to the baseline DOCX file."),
429
+ modified_path: z
430
+ .string()
431
+ .describe("Absolute path to the modified DOCX file."),
432
+ compare_clean: z
433
+ .boolean()
434
+ .default(true)
435
+ .describe(
436
+ "If True, compares 'Accepted' state. If False, compares raw text.",
437
+ ),
438
+ },
439
+ },
440
+ async ({ original_path, modified_path, compare_clean }) => {
441
+ try {
442
+ const origBuf = readFileBytesOrThrow(original_path);
443
+ const modBuf = readFileBytesOrThrow(modified_path);
427
444
 
428
- return {
429
- content: [{ type: "text", text: diff || "No differences found." }],
430
- };
431
- }
445
+ const origText = await extractTextFromBuffer(origBuf, compare_clean);
446
+ const modText = await extractTextFromBuffer(modBuf, compare_clean);
432
447
 
433
- if (name === "finalize_document") {
434
- const filePath = args?.file_path as string;
435
- let outPath = args?.output_path as string;
448
+ const diff = create_word_patch_diff(
449
+ origText,
450
+ modText,
451
+ basename(original_path),
452
+ basename(modified_path),
453
+ );
436
454
 
437
- if (!outPath) {
438
- const ext = extname(filePath);
439
- const base = basename(filePath, ext);
440
- const dir = dirname(filePath);
441
- outPath = resolve(dir, `${base}_final${ext}`);
442
- }
455
+ return {
456
+ content: [{ type: "text", text: diff || "No differences found." }],
457
+ };
458
+ } catch (e: any) {
459
+ return {
460
+ isError: true,
461
+ content: [{ type: "text", text: `Error: ${e.message}` }],
462
+ };
463
+ }
464
+ },
465
+ );
443
466
 
444
- const buf = readFileBytesOrThrow(filePath);
445
- const doc = await DocumentObject.load(buf);
467
+ server.registerTool(
468
+ "finalize_document",
469
+ {
470
+ description:
471
+ "Prepares a document for external distribution or e-signature.",
472
+ inputSchema: {
473
+ file_path: z.string().describe("Absolute path to the DOCX file."),
474
+ output_path: z.string().optional().describe("Optional output path."),
475
+ sanitize_mode: z
476
+ .enum(["full", "keep-markup"])
477
+ .optional()
478
+ .describe("full removes all markup, keep-markup redacts metadata."),
479
+ accept_all: z
480
+ .boolean()
481
+ .optional()
482
+ .describe(
483
+ "If true, auto-accepts all unresolved track changes before finalizing.",
484
+ ),
485
+ protection_mode: z
486
+ .enum(["read_only", "encrypt"])
487
+ .optional()
488
+ .describe("Native OOXML document locking."),
489
+ password: z.string().optional().describe("Ignored in this environment."),
490
+ author: z
491
+ .string()
492
+ .optional()
493
+ .describe("Replace all remaining markup authorship with this name."),
494
+ export_pdf: z
495
+ .boolean()
496
+ .optional()
497
+ .describe("Ignored in this environment."),
498
+ },
499
+ },
500
+ async ({
501
+ file_path,
502
+ output_path,
503
+ sanitize_mode,
504
+ accept_all,
505
+ protection_mode,
506
+ author,
507
+ export_pdf,
508
+ }) => {
509
+ try {
510
+ let outPath = output_path;
511
+ if (!outPath) {
512
+ const ext = extname(file_path);
513
+ const base = basename(file_path, ext);
514
+ const dir = dirname(file_path);
515
+ outPath = resolve(dir, `${base}_final${ext}`);
516
+ }
446
517
 
447
- const result = await finalize_document(doc, {
448
- filename: basename(filePath),
449
- sanitize_mode: (args?.sanitize_mode as any) || "full",
450
- accept_all: args?.accept_all as boolean,
451
- protection_mode: args?.protection_mode as any,
452
- author: args?.author as string,
453
- export_pdf: args?.export_pdf as boolean,
454
- });
518
+ const buf = readFileBytesOrThrow(file_path);
519
+ const doc = await DocumentObject.load(buf);
455
520
 
456
- const fs = await import("node:fs");
457
- fs.writeFileSync(outPath, result.outBuffer!);
521
+ const result = await finalize_document(doc, {
522
+ filename: basename(file_path),
523
+ sanitize_mode: (sanitize_mode as any) || "full",
524
+ accept_all: accept_all as boolean,
525
+ protection_mode: protection_mode as any,
526
+ author: author as string,
527
+ export_pdf: export_pdf as boolean,
528
+ });
529
+
530
+ fs.writeFileSync(outPath, result.outBuffer!);
458
531
 
459
- return {
460
- content: [
461
- {
462
- type: "text",
463
- text: `Saved to: ${outPath}\n\n${result.reportText}`,
464
- },
465
- ],
466
- };
467
- }
468
- if (name === "login_to_adeu_cloud") {
469
- return await login_to_adeu_cloud();
470
- }
471
- if (name === "logout_of_adeu_cloud") {
472
- return await logout_of_adeu_cloud();
473
- }
474
- if (name === "search_and_fetch_emails") {
475
- return await search_and_fetch_emails(args || {});
476
- }
477
- if (name === "create_email_draft") {
478
- return await create_email_draft(args || {});
479
- }
480
- throw new Error(`Unknown tool: ${name}`);
481
- } catch (error: any) {
482
532
  return {
483
533
  content: [
484
534
  {
485
535
  type: "text",
486
- text: `Error executing tool ${name}: ${error.message}`,
536
+ text: `Saved to: ${outPath}\n\n${result.reportText}`,
487
537
  },
488
538
  ],
539
+ };
540
+ } catch (e: any) {
541
+ return {
489
542
  isError: true,
543
+ content: [{ type: "text", text: `Error: ${e.message}` }],
490
544
  };
491
545
  }
492
546
  },
493
547
  );
548
+ server.registerTool(
549
+ "login_to_adeu_cloud",
550
+ { description: "Logs the user into the Adeu Cloud backend." },
551
+ async () => {
552
+ try {
553
+ return (await login_to_adeu_cloud()) as any;
554
+ } catch (e: any) {
555
+ return { isError: true, content: [{ type: "text", text: e.message }] };
556
+ }
557
+ },
558
+ );
559
+
560
+ server.registerTool(
561
+ "logout_of_adeu_cloud",
562
+ { description: "Logs out of the Adeu Cloud backend." },
563
+ async () => {
564
+ try {
565
+ return (await logout_of_adeu_cloud()) as any;
566
+ } catch (e: any) {
567
+ return { isError: true, content: [{ type: "text", text: e.message }] };
568
+ }
569
+ },
570
+ );
571
+ server.registerTool(
572
+ "create_email_draft",
573
+ {
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.",
582
+ inputSchema: {
583
+ body_markdown: z.string(),
584
+ reply_to_email_id: z.string().optional(),
585
+ subject: z.string().optional(),
586
+ to_recipients: z.array(z.string()).optional(),
587
+ attachment_paths: z.array(z.string()).optional(),
588
+ mailbox_address: z
589
+ .string()
590
+ .optional()
591
+ .describe(
592
+ "Optional target mailbox email address to create the draft in.",
593
+ ),
594
+ },
595
+ },
596
+ async (args) => {
597
+ try {
598
+ return (await create_email_draft(args)) as any;
599
+ } catch (e: any) {
600
+ return { isError: true, content: [{ type: "text", text: e.message }] };
601
+ }
602
+ },
603
+ );
604
+ server.registerTool(
605
+ "list_available_mailboxes",
606
+ {
607
+ description:
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.",
611
+ inputSchema: {},
612
+ },
613
+ async () => {
614
+ try {
615
+ return (await list_available_mailboxes()) as any;
616
+ } catch (e: any) {
617
+ return { isError: true, content: [{ type: "text", text: e.message }] };
618
+ }
619
+ },
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
+ }
494
664
 
495
665
  // --- Startup ---
496
666
  async function main() {