@adeu/mcp-server 1.10.1 → 1.12.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
@@ -5,7 +5,7 @@ import { readFileSync, existsSync } from "node:fs";
5
5
  import { basename, resolve, extname, dirname, join } from "node:path";
6
6
  import { z } from "zod";
7
7
  import {
8
- registerAppTool,
8
+ registerAppTool as origRegisterAppTool,
9
9
  registerAppResource,
10
10
  RESOURCE_MIME_TYPE,
11
11
  } from "@modelcontextprotocol/ext-apps/server";
@@ -13,6 +13,7 @@ import fs from "node:fs";
13
13
  import {
14
14
  identifyEngine,
15
15
  extractTextFromBuffer,
16
+ _extractTextFromDoc,
16
17
  DocumentObject,
17
18
  RedlineEngine,
18
19
  BatchValidationError,
@@ -39,7 +40,12 @@ function readFileBytesOrThrow(filePath: string): Buffer {
39
40
  return readFileSync(filePath);
40
41
  } catch (err: any) {
41
42
  if (err.code === "ENOENT") {
42
- throw new Error(`File not found: ${filePath}`);
43
+ 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.`
48
+ );
43
49
  }
44
50
  throw err;
45
51
  }
@@ -74,12 +80,38 @@ const PROCESS_BATCH_OPERATIONS_DESC =
74
80
  const DIFF_DOCX_DESC =
75
81
  "Compares two DOCX files and returns a unified diff of their text content. Useful for analyzing differences between versions before editing.";
76
82
 
83
+ const gitSha = process.env.GIT_SHA || "unknown";
84
+ const buildTs = process.env.BUILD_TIMESTAMP || "unknown";
85
+ const packageVersion = process.env.PACKAGE_VERSION || "unknown";
86
+ const buildTag = ` [Adeu v${packageVersion}+${gitSha}]`;
87
+
77
88
  // --- Server Setup ---
78
89
  const server = new McpServer({
79
90
  name: "adeu-redlining-service",
80
- version: "1.0.0",
91
+ version: packageVersion,
81
92
  });
82
93
 
94
+ // Wrap server.registerTool to inject buildTag into descriptions
95
+ const originalRegisterTool = server.registerTool.bind(server);
96
+ server.registerTool = (name: string, schema: any, handler?: any) => {
97
+ if (schema && typeof schema === "object") {
98
+ if (schema.description) {
99
+ schema.description = schema.description.trim() + buildTag;
100
+ }
101
+ }
102
+ return originalRegisterTool(name, schema, handler);
103
+ };
104
+
105
+ // Wrap registerAppTool to inject buildTag into descriptions
106
+ const registerAppTool: typeof origRegisterAppTool = (mcpServer, name, schema, handler) => {
107
+ if (schema && typeof schema === "object") {
108
+ if (schema.description) {
109
+ schema.description = schema.description.trim() + buildTag;
110
+ }
111
+ }
112
+ return origRegisterAppTool(mcpServer, name, schema, handler);
113
+ };
114
+
83
115
  // Common CSP allowing Google Fonts used by Adeu UI templates
84
116
  const UI_CSP = {
85
117
  connectDomains: ["https://fonts.googleapis.com", "https://fonts.gstatic.com"],
@@ -205,22 +237,31 @@ registerAppTool(
205
237
  }) => {
206
238
  try {
207
239
  const buf = readFileBytesOrThrow(file_path);
208
- const text = await extractTextFromBuffer(buf, clean_view);
209
240
 
210
241
  if (mode === "outline") {
211
242
  const doc = await DocumentObject.load(buf);
212
- return build_outline_response(
243
+ const extract_res = _extractTextFromDoc(doc, clean_view, true, true) as {
244
+ text: string;
245
+ paragraph_offsets: Map<any, [number, number]>;
246
+ };
247
+ const res = build_outline_response(
213
248
  doc,
214
- text,
249
+ extract_res.text,
215
250
  file_path,
216
251
  outline_max_level,
217
252
  outline_verbose,
218
- ) as any;
253
+ extract_res.paragraph_offsets,
254
+ );
255
+ return res as any;
219
256
  }
257
+
258
+ const text = await extractTextFromBuffer(buf, clean_view);
220
259
  if (mode === "appendix") {
221
- return build_appendix_response(text, page, file_path) as any;
260
+ const res = build_appendix_response(text, page, file_path);
261
+ return res as any;
222
262
  }
223
- return build_paginated_response(text, page, file_path) as any;
263
+ const res = build_paginated_response(text, page, file_path);
264
+ return res as any;
224
265
  } catch (e: any) {
225
266
  return {
226
267
  isError: true,
@@ -235,6 +276,8 @@ registerAppTool(
235
276
  },
236
277
  );
237
278
 
279
+
280
+
238
281
  registerAppTool(
239
282
  server,
240
283
  "search_and_fetch_emails",
@@ -268,6 +311,10 @@ registerAppTool(
268
311
  .string()
269
312
  .optional()
270
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."),
271
318
  max_attachment_size_mb: z
272
319
  .number()
273
320
  .optional()
@@ -683,8 +730,10 @@ export function formatBatchResult(
683
730
  async function main() {
684
731
  const transport = new StdioServerTransport();
685
732
  await server.connect(transport);
733
+ const gitSha = process.env.GIT_SHA || "unknown";
734
+ const buildTs = process.env.BUILD_TIMESTAMP || "unknown";
686
735
  console.error(
687
- `Adeu MCP Server (Node.js Engine: ${identifyEngine()}) running on stdio`,
736
+ `Adeu MCP Server (Node.js Engine: ${identifyEngine()}) running on stdio build=${gitSha}@${buildTs}`,
688
737
  );
689
738
  }
690
739
 
@@ -157,6 +157,8 @@ describe("Resolved Bugs MCP Server Verification", () => {
157
157
  expect(res.result.content[0].text).toContain(
158
158
  "Error executing tool read_docx: File not found:",
159
159
  );
160
+ expect(res.result.content[0].text).toContain("sandboxed/containerized environment");
161
+ expect(res.result.content[0].text).toContain("uv tool install adeu");
160
162
  expect(res.result.content[0].text).not.toContain("ENOENT"); // Raw node error must not leak
161
163
  });
162
164
  });
@@ -0,0 +1,265 @@
1
+ // FILE: node/packages/mcp-server/src/parity_live.test.ts
2
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
3
+ import { spawn, ChildProcess } from "node:child_process";
4
+ import { resolve, join } from "node:path";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+
7
+ describe("Parity Live Server Integration Verification", () => {
8
+ let serverProc: ChildProcess;
9
+ const fixturePath = resolve(__dirname, "../tests/fixtures/gap2_minimal_repro.docx");
10
+
11
+ beforeAll(async () => {
12
+ const serverPath = resolve(__dirname, "../dist/index.js");
13
+ if (!existsSync(serverPath)) {
14
+ throw new Error(
15
+ "MCP server not built. Run 'npm run build' before tests.",
16
+ );
17
+ }
18
+ if (!existsSync(fixturePath)) {
19
+ throw new Error(`Fixture not found: ${fixturePath}`);
20
+ }
21
+
22
+ // Spawn server process
23
+ serverProc = spawn("node", [serverPath]);
24
+ });
25
+
26
+ afterAll(() => {
27
+ if (serverProc && !serverProc.killed) {
28
+ serverProc.kill();
29
+ }
30
+ });
31
+
32
+ // Helper to interact with the stdio JSON-RPC server
33
+ function sendRpc(method: string, params: any, id: number = 1): Promise<any> {
34
+ return new Promise((resolve, reject) => {
35
+ const timeout = setTimeout(() => reject(new Error("RPC Timeout")), 5000);
36
+
37
+ const listener = (data: Buffer) => {
38
+ const lines = data.toString().trim().split("\n");
39
+ for (const line of lines) {
40
+ if (!line.startsWith("{")) continue;
41
+ try {
42
+ const res = JSON.parse(line);
43
+ if (res.id === id) {
44
+ clearTimeout(timeout);
45
+ serverProc.stdout?.removeListener("data", listener);
46
+ resolve(res);
47
+ }
48
+ } catch (e) {
49
+ // Ignore incomplete chunks
50
+ }
51
+ }
52
+ };
53
+
54
+ serverProc.stdout?.on("data", listener);
55
+ serverProc.stdin?.write(
56
+ JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n",
57
+ );
58
+ });
59
+ }
60
+
61
+ it("does not expose the server_info tool anymore", async () => {
62
+ const listRes = await sendRpc("tools/list", {}, 200);
63
+ expect(listRes.result).toBeDefined();
64
+ const tools = listRes.result.tools || [];
65
+ const serverInfoTool = tools.find((t: any) => t.name === "server_info");
66
+ expect(serverInfoTool).toBeUndefined();
67
+ });
68
+
69
+ it("exposes the build stamp via tool-call descriptions", async () => {
70
+ const listRes = await sendRpc("tools/list", {}, 201);
71
+ expect(listRes.result).toBeDefined();
72
+ const tools = listRes.result.tools || [];
73
+ const readDocx = tools.find((t: any) => t.name === "read_docx");
74
+ expect(readDocx).toBeDefined();
75
+ expect(readDocx.description).toContain("[Adeu v");
76
+ });
77
+
78
+ it("exposes the build stamp via serverInfo.version in initialize", async () => {
79
+ const initRes = await sendRpc("initialize", {
80
+ protocolVersion: "2024-11-05",
81
+ capabilities: {},
82
+ clientInfo: { name: "test-client", version: "1.0.0" }
83
+ }, 202);
84
+ expect(initRes.result).toBeDefined();
85
+ expect(initRes.result.serverInfo).toBeDefined();
86
+ expect(initRes.result.serverInfo.version).not.toBe("1.0.0");
87
+ expect(initRes.result.serverInfo.version).not.toContain("unknown");
88
+ });
89
+
90
+
91
+ it("read_docx appends [Debug] build stamp footer to mode=outline and mode=full", async () => {
92
+ const res = await sendRpc("tools/call", {
93
+ name: "read_docx",
94
+ arguments: {
95
+ file_path: fixturePath,
96
+ mode: "outline",
97
+ },
98
+ }, 202);
99
+
100
+ expect(res.result).toBeDefined();
101
+ expect(res.result.content[0].text).not.toContain("[Debug] build=");
102
+ });
103
+
104
+ it("GAP 2 (Live): process_document_batch modify straddling deleted text returns actionable deletion error", async () => {
105
+ const outPath = join(resolve(__dirname, "../../../../tmp"), `live_gap2_out_${Date.now()}.docx`);
106
+ const res = await sendRpc("tools/call", {
107
+ name: "process_document_batch",
108
+ arguments: {
109
+ original_docx_path: fixturePath,
110
+ author_name: "Ed",
111
+ changes: [
112
+ {
113
+ type: "modify",
114
+ target_text: "Foo bar old phrase here.",
115
+ new_text: "X",
116
+ comment: "c",
117
+ }
118
+ ],
119
+ output_path: outPath,
120
+ dry_run: true,
121
+ },
122
+ }, 203);
123
+
124
+ expect(res.result).toBeDefined();
125
+ const text = res.result.content[0].text;
126
+ expect(text).toContain("Edit 1 Failed: Target text matches text inside a tracked deletion by Test Negotiator.");
127
+ expect(text).toContain("Reject/accept that change first or target the active replacement text instead.");
128
+ expect(text).not.toContain("Target text not found in document.");
129
+
130
+ // Run Python engine to assert cross-engine exact error parity
131
+ const { execSync } = await import("node:child_process");
132
+ const projectRoot = resolve(__dirname, "../../../..");
133
+ const pythonCli = process.platform === "win32"
134
+ ? join(projectRoot, "python/.venv/Scripts/adeu.exe")
135
+ : join(projectRoot, "python/.venv/bin/adeu");
136
+
137
+ // Create temporary changes JSON for Python CLI
138
+ const fs = await import("node:fs");
139
+ const tempJsonPath = join(projectRoot, "tmp", `changes_${Date.now()}.json`);
140
+ fs.mkdirSync(join(projectRoot, "tmp"), { recursive: true });
141
+ fs.writeFileSync(tempJsonPath, JSON.stringify([
142
+ {
143
+ type: "modify",
144
+ target_text: "Foo bar old phrase here.",
145
+ new_text: "X",
146
+ comment: "c",
147
+ }
148
+ ]));
149
+
150
+ try {
151
+ const pythonOut = execSync(`"${pythonCli}" apply "${fixturePath}" "${tempJsonPath}" --dry-run --author Ed`, {
152
+ encoding: "utf-8",
153
+ });
154
+ // Should not succeed normally since dry run exits with code 1 on errors, so we catch in the try block
155
+ } catch (err: any) {
156
+ const pyErrorOutput = err.stdout || err.stderr || "";
157
+ expect(pyErrorOutput).toContain("Edit 1 Failed: Target text matches text inside a tracked deletion by Test Negotiator.");
158
+ expect(pyErrorOutput).toContain("Reject/accept that change first or target the active replacement text instead.");
159
+ } finally {
160
+ if (fs.existsSync(tempJsonPath)) {
161
+ fs.unlinkSync(tempJsonPath);
162
+ }
163
+ }
164
+ });
165
+
166
+ it("GAP 1 (Live): read_docx mode=outline heading count and content parity with Python", async () => {
167
+ const gap1FixturePath = resolve(__dirname, "../tests/fixtures/gap1_deleted_row_repro.docx");
168
+ const { execSync } = await import("node:child_process");
169
+ const projectRoot = resolve(__dirname, "../../../..");
170
+ const pythonCli = process.platform === "win32"
171
+ ? join(projectRoot, "python/.venv/Scripts/adeu.exe")
172
+ : join(projectRoot, "python/.venv/bin/adeu");
173
+
174
+ // 1. clean_view = true
175
+ const nodeResClean = await sendRpc("tools/call", {
176
+ name: "read_docx",
177
+ arguments: {
178
+ file_path: gap1FixturePath,
179
+ mode: "outline",
180
+ clean_view: true,
181
+ },
182
+ }, 204);
183
+
184
+ expect(nodeResClean.result).toBeDefined();
185
+ const nodeTextClean = nodeResClean.result.content[0].text;
186
+
187
+ // Get Python output for clean_view = true
188
+ const pythonOutClean = execSync(`"${pythonCli}" extract "${gap1FixturePath}" --mode outline --clean-view`, {
189
+ encoding: "utf-8",
190
+ });
191
+
192
+ // Extract raw markdown headings (lines starting with #) to assert perfect parity
193
+ const getHeadings = (text: string) => text.split("\n").filter(line => line.startsWith("#")).map(l => l.trim());
194
+
195
+ const nodeHeadingsClean = getHeadings(nodeTextClean);
196
+ const pythonHeadingsClean = getHeadings(pythonOutClean);
197
+
198
+ expect(nodeHeadingsClean).toEqual(["# Active Heading (p1)"]);
199
+ expect(pythonHeadingsClean).toEqual(["# Active Heading (p1)"]);
200
+ expect(nodeHeadingsClean).toEqual(pythonHeadingsClean);
201
+
202
+ // 2. clean_view = false
203
+ const nodeResDirty = await sendRpc("tools/call", {
204
+ name: "read_docx",
205
+ arguments: {
206
+ file_path: gap1FixturePath,
207
+ mode: "outline",
208
+ clean_view: false,
209
+ },
210
+ }, 205);
211
+
212
+ expect(nodeResDirty.result).toBeDefined();
213
+ const nodeTextDirty = nodeResDirty.result.content[0].text;
214
+
215
+ const pythonOutDirty = execSync(`"${pythonCli}" extract "${gap1FixturePath}" --mode outline`, {
216
+ encoding: "utf-8",
217
+ });
218
+
219
+ const nodeHeadingsDirty = getHeadings(nodeTextDirty);
220
+ const pythonHeadingsDirty = getHeadings(pythonOutDirty);
221
+
222
+ expect(nodeHeadingsDirty).toEqual(["# Active Heading (p1)", "# Deleted Heading (p1)"]);
223
+ expect(pythonHeadingsDirty).toEqual(["# Active Heading (p1)", "# Deleted Heading (p1)"]);
224
+ expect(nodeHeadingsDirty).toEqual(pythonHeadingsDirty);
225
+ });
226
+
227
+ it("GAP 1 - Style Def (Live): style-definition outlineLvl on non-heading style classification parity", async () => {
228
+ const gap1FixturePath = resolve(__dirname, "../tests/fixtures/gap1_minimal_repro.docx");
229
+ const { execSync } = await import("node:child_process");
230
+ const projectRoot = resolve(__dirname, "../../../..");
231
+ const pythonCli = process.platform === "win32"
232
+ ? join(projectRoot, "python/.venv/Scripts/adeu.exe")
233
+ : join(projectRoot, "python/.venv/bin/adeu");
234
+
235
+ // 1. clean_view = true (both engines must return exactly 2 headings, excluding Subtitle)
236
+ const nodeResClean = await sendRpc("tools/call", {
237
+ name: "read_docx",
238
+ arguments: {
239
+ file_path: gap1FixturePath,
240
+ mode: "outline",
241
+ clean_view: true,
242
+ },
243
+ }, 206);
244
+
245
+ expect(nodeResClean.result).toBeDefined();
246
+ const nodeTextClean = nodeResClean.result.content[0].text;
247
+
248
+ const pythonOutClean = execSync(`"${pythonCli}" extract "${gap1FixturePath}" --mode outline --clean-view`, {
249
+ encoding: "utf-8",
250
+ });
251
+
252
+ const getHeadings = (text: string) => text.split("\n").filter(line => line.startsWith("#")).map(l => l.trim());
253
+
254
+ const nodeHeadingsClean = getHeadings(nodeTextClean);
255
+ const pythonHeadingsClean = getHeadings(pythonOutClean);
256
+
257
+ // Assert exact count parity (must be exactly 2 for both)
258
+ expect(nodeHeadingsClean.length).toBe(2);
259
+ expect(pythonHeadingsClean.length).toBe(2);
260
+
261
+ expect(nodeHeadingsClean).toEqual(["# Real Heading One (p1)", "# Real Heading Two (p1)"]);
262
+ expect(pythonHeadingsClean).toEqual(["# Real Heading One (p1)", "# Real Heading Two (p1)"]);
263
+ expect(nodeHeadingsClean).toEqual(pythonHeadingsClean);
264
+ });
265
+ });
@@ -110,6 +110,7 @@ export function build_outline_response(
110
110
  file_path: string,
111
111
  outline_max_level: number = 2,
112
112
  outline_verbose: boolean = false,
113
+ paragraph_offsets: Map<any, [number, number]> | null = null,
113
114
  ): ToolResult {
114
115
  const [body] = split_structural_appendix(projected_text);
115
116
  const pagination_result = paginate(body, "");
@@ -119,6 +120,7 @@ export function build_outline_response(
119
120
  body,
120
121
  pagination_result.body_pages,
121
122
  pagination_result.body_page_offsets,
123
+ paragraph_offsets,
122
124
  );
123
125
 
124
126
  const rendered = render_outline_tree(
@@ -1,4 +1,3 @@
1
- // FILE: node/packages/mcp-server/src/tools/email.test.ts
2
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
2
  import { search_and_fetch_emails, list_available_mailboxes } from "./email.js";
4
3
 
@@ -255,4 +254,108 @@ describe("Node Email Tools Finding #2 and Finding #6 tests", () => {
255
254
  "You can now use tools like `read_docx`, `diff_docx_files`, or `finalize_document`",
256
255
  );
257
256
  });
257
+
258
+ describe("Stateful Polling symmetry with Validation", () => {
259
+ const sleepMock = vi.spyOn(global, "setTimeout");
260
+
261
+ beforeEach(() => {
262
+ vi.useFakeTimers();
263
+ });
264
+
265
+ afterEach(() => {
266
+ vi.useRealTimers();
267
+ });
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);
279
+
280
+ const result = await search_and_fetch_emails({ subject: "heavy search" });
281
+
282
+ expect(result.content[0].text).toContain("Email processing task started successfully");
283
+ expect(result.content[0].text).toContain("task_id=email_task_typescript_123");
284
+ expect(result.structuredContent?.status).toBe("pending");
285
+ expect(result.structuredContent?.task_id).toBe("email_task_typescript_123");
286
+ });
287
+
288
+ it("should poll and resolve when a task completes successfully", async () => {
289
+ let callCount = 0;
290
+ global.fetch = vi.fn().mockImplementation(async () => {
291
+ callCount++;
292
+ if (callCount === 1) {
293
+ return {
294
+ ok: true,
295
+ status: 200,
296
+ json: async () => ({ status: "PENDING" }),
297
+ } as Response;
298
+ }
299
+ return {
300
+ ok: true,
301
+ status: 200,
302
+ json: async () => ({
303
+ status: "COMPLETED",
304
+ type: "previews",
305
+ previews: [],
306
+ }),
307
+ } as Response;
308
+ });
309
+
310
+ const promise = search_and_fetch_emails({ task_id: "email_task_typescript_123" });
311
+ promise.catch(() => {}); // Suppress Vitest's unhandled rejection warnings during timer advance
312
+ await vi.runAllTimersAsync();
313
+
314
+ const result = await promise;
315
+
316
+ expect(callCount).toBe(2);
317
+ expect(result.content[0].text).toContain("No emails found matching your search criteria.");
318
+ });
319
+
320
+ it("should throw standard ToolError on task failure", async () => {
321
+ global.fetch = vi.fn().mockResolvedValue({
322
+ ok: true,
323
+ status: 200,
324
+ json: async () => ({
325
+ status: "FAILED",
326
+ error: "API authorization revoked.",
327
+ }),
328
+ } as Response);
329
+
330
+ const promise = search_and_fetch_emails({ task_id: "email_task_typescript_123" });
331
+ const caughtPromise = promise.catch((err) => err);
332
+ await vi.runAllTimersAsync();
333
+
334
+ await expect(promise).rejects.toThrowError(
335
+ "Validation task failed on the server: API authorization revoked."
336
+ );
337
+ });
338
+
339
+ it("should gracefully return a pending status on polling timeout (50s)", async () => {
340
+ global.fetch = vi.fn().mockResolvedValue({
341
+ ok: true,
342
+ status: 200,
343
+ json: async () => ({ status: "PENDING" }),
344
+ } as Response);
345
+
346
+ const promise = search_and_fetch_emails({ task_id: "email_task_typescript_123" });
347
+
348
+ // Advance all 10 polling intervals
349
+ for (let i = 0; i < 10; i++) {
350
+ await vi.advanceTimersByTimeAsync(5000);
351
+ }
352
+ await vi.runAllTimersAsync();
353
+
354
+ const result = await promise;
355
+ expect(result.content[0].text).toContain("is still processing");
356
+ expect(result.content[0].text).toContain("task_id=email_task_typescript_123");
357
+ expect(result.structuredContent?.status).toBe("pending");
358
+ expect(result.structuredContent?.task_id).toBe("email_task_typescript_123");
359
+ });
360
+ });
258
361
  });