@adeu/mcp-server 1.13.0 → 1.15.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.
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatBatchResult } from "./index.js";
3
+
4
+ describe("QA Report V2: Formatter Parity", () => {
5
+ it("F3 & F4: Formats match_mode, occurrences_modified, heading_path, and pages correctly", () => {
6
+ // Mock the stats object that the engine produces when `all` mode is successful
7
+ const stats = {
8
+ actions_applied: 0,
9
+ actions_skipped: 0,
10
+ edits_applied: 1,
11
+ edits_skipped: 0,
12
+ edits: [
13
+ {
14
+ status: "applied",
15
+ target_text: "the Board of Directors",
16
+ new_text: "the Governing Body",
17
+ warning: null,
18
+ error: null,
19
+ critic_markup: "the {--Board of Directors--}{++Governing Body++}",
20
+ clean_text: "the Governing Body",
21
+ pages: [3, 12],
22
+ heading_path: "6. Term > 6.1",
23
+ occurrences_modified: 33,
24
+ match_mode: "all"
25
+ }
26
+ ],
27
+ skipped_details: []
28
+ };
29
+
30
+ const res = formatBatchResult(stats, "dummy.docx", false);
31
+
32
+ // Verify the new §5.4.2 visual format
33
+ expect(res).toContain("### Edit 1 ✅ [applied] (p3, p12)");
34
+ expect(res).toContain("**Path:** `6. Term > 6.1`");
35
+ expect(res).toContain("**Mode:** `all` (33 occurrences modified)");
36
+ expect(res).toContain("*Preview (CriticMarkup):*");
37
+ expect(res).toContain("> the {--Board of Directors--}{++Governing Body++}");
38
+ expect(res).toContain("*Preview (Clean):*");
39
+ expect(res).toContain("> the Governing Body");
40
+ });
41
+
42
+ it("R1: Formats dry-run with full enrichment fields identical to real writes", () => {
43
+ const stats = {
44
+ actions_applied: 0,
45
+ actions_skipped: 0,
46
+ edits_applied: 1,
47
+ edits_skipped: 0,
48
+ edits: [
49
+ {
50
+ status: "applied",
51
+ target_text: "Target",
52
+ new_text: "Replaced",
53
+ warning: null,
54
+ error: null,
55
+ critic_markup: "{--Target--}{++Replaced++}",
56
+ clean_text: "Replaced",
57
+ pages: [1],
58
+ heading_path: "1. Intro",
59
+ occurrences_modified: 1,
60
+ match_mode: "all"
61
+ }
62
+ ],
63
+ skipped_details: []
64
+ };
65
+
66
+ const res = formatBatchResult(stats, "dummy.docx", true);
67
+ expect(res).toContain("**Path:** `1. Intro`");
68
+ expect(res).toContain("**Mode:** `all` (1 occurrence modified)");
69
+ expect(res).toContain("(p1)");
70
+ });
71
+ });
@@ -16,7 +16,11 @@ describe("MCP Server Tool Output Formatter", () => {
16
16
  warning: "Warning: target_text contains punctuation.",
17
17
  error: null,
18
18
  critic_markup: "The {--quick brown fox--}{++fast red fox++} jumps over",
19
- clean_text: "The fast red fox jumps over"
19
+ clean_text: "The fast red fox jumps over",
20
+ match_mode: "strict",
21
+ occurrences_modified: 1,
22
+ pages: [1],
23
+ heading_path: "1. Intro"
20
24
  }
21
25
  ],
22
26
  skipped_details: []
@@ -29,9 +33,11 @@ describe("MCP Server Tool Output Formatter", () => {
29
33
  expect(res).toContain("Actions: 0 applied");
30
34
  expect(res).toContain("Edits: 1 applied");
31
35
  expect(res).toContain("Detailed Edit Reports:");
32
- expect(res).toContain("✅ [applied]");
33
- expect(res).toContain("Warning: Warning: target_text contains punctuation.");
34
- expect(res).toContain("Preview (CriticMarkup): The {--quick brown fox--}{++fast red fox++} jumps over");
36
+ expect(res).toContain("### Edit 1 ✅ [applied] (p1)");
37
+ expect(res).toContain("**Path:** `1. Intro`");
38
+ expect(res).toContain("**Mode:** `strict` (1 occurrence modified)");
39
+ expect(res).toContain("*Warning:* Warning: target_text contains punctuation.");
40
+ expect(res).toContain("*Preview (CriticMarkup):*\n> The {--quick brown fox--}{++fast red fox++} jumps over");
35
41
  });
36
42
 
37
43
  it("formats a failed batch result correctly", () => {
@@ -57,8 +63,8 @@ describe("MCP Server Tool Output Formatter", () => {
57
63
  const res = formatBatchResult(stats, "dummy_processed.docx", false);
58
64
 
59
65
  expect(res).toContain("Batch complete. Saved to: dummy_processed.docx");
60
- expect(res).toContain("❌ [failed]");
61
- expect(res).toContain("Error: Target text not found in document");
66
+ expect(res).toContain("### Edit 1 ❌ [failed]");
67
+ expect(res).toContain("*Error:* Target text not found in document");
62
68
  expect(res).toContain("Skipped Details:\n- Failed to apply edit targeting: 'NON_EXISTENT...'");
63
69
  });
64
70
  });
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  build_paginated_response,
25
25
  build_outline_response,
26
26
  build_appendix_response,
27
+ build_search_response,
27
28
  } from "./response-builders.js";
28
29
 
29
30
  import { login_to_adeu_cloud, logout_of_adeu_cloud } from "./tools/auth.js";
@@ -224,9 +225,9 @@ registerAppTool(
224
225
  "'full' returns body content. 'outline' returns a structural heading map. 'appendix' returns defined terms.",
225
226
  ),
226
227
  page: z
227
- .number()
228
+ .union([z.number(), z.literal("all")])
228
229
  .default(1)
229
- .describe("Page number (1-indexed) for mode='full'. Defaults to 1."),
230
+ .describe("Page number (1-indexed) for mode='full', or 'all' to return unpaginated."),
230
231
  outline_max_level: z
231
232
  .number()
232
233
  .default(2)
@@ -235,6 +236,18 @@ registerAppTool(
235
236
  .boolean()
236
237
  .default(false)
237
238
  .describe("For mode='outline' only: includes metadata."),
239
+ search_query: z
240
+ .string()
241
+ .optional()
242
+ .describe("The substring or regex pattern to search for. When provided, filters results to matching paragraphs."),
243
+ search_regex: z
244
+ .boolean()
245
+ .default(false)
246
+ .describe("Set to true to interpret search_query as a regular expression."),
247
+ search_case_sensitive: z
248
+ .boolean()
249
+ .default(true)
250
+ .describe("Set to false to perform case-insensitive matching."),
238
251
  }),
239
252
  _meta: { ui: { resourceUri: MARKDOWN_UI_URI } },
240
253
  },
@@ -245,6 +258,9 @@ registerAppTool(
245
258
  page,
246
259
  outline_max_level,
247
260
  outline_verbose,
261
+ search_query,
262
+ search_regex,
263
+ search_case_sensitive,
248
264
  }) => {
249
265
  try {
250
266
  const buf = readFileBytesOrThrow(file_path);
@@ -267,11 +283,15 @@ registerAppTool(
267
283
  }
268
284
 
269
285
  const text = await extractTextFromBuffer(buf, clean_view);
286
+ if (search_query !== undefined && search_query !== null) {
287
+ const res = build_search_response(text, search_query, search_regex, search_case_sensitive, page, file_path);
288
+ return res as any;
289
+ }
270
290
  if (mode === "appendix") {
271
- const res = build_appendix_response(text, page, file_path);
291
+ const res = build_appendix_response(text, typeof page === "number" ? page : parseInt(page, 10) || 1, file_path);
272
292
  return res as any;
273
293
  }
274
- const res = build_paginated_response(text, page, file_path);
294
+ const res = build_paginated_response(text, typeof page === "number" ? page : parseInt(page, 10) || 1, file_path);
275
295
  return res as any;
276
296
  } catch (e: any) {
277
297
  return {
@@ -334,6 +354,28 @@ server.registerTool(
334
354
  content: [{ type: "text", text: "Error: No changes provided." }],
335
355
  };
336
356
 
357
+ // Defensive sanitization at the MCP boundary: some LLM clients
358
+ // "double-serialize" nested arrays, delivering each element of `changes`
359
+ // as a JSON string instead of an object. The core engine also guards
360
+ // against this, but we normalize here too so the tool layer never hands
361
+ // raw string primitives downstream regardless of the engine version
362
+ // bundled. Genuine objects and unparseable strings pass through
363
+ // untouched so validation surfaces a clear error rather than crashing.
364
+ const sanitizedChanges = changes.map((item: any) => {
365
+ if (typeof item === "string") {
366
+ try {
367
+ const parsed = JSON.parse(item);
368
+ if (parsed !== null && typeof parsed === "object") {
369
+ return parsed;
370
+ }
371
+ return item;
372
+ } catch {
373
+ return item;
374
+ }
375
+ }
376
+ return item;
377
+ });
378
+
337
379
  let outPath = output_path;
338
380
  if (!outPath) {
339
381
  const ext = extname(original_docx_path);
@@ -348,7 +390,7 @@ server.registerTool(
348
390
 
349
391
  let stats;
350
392
  try {
351
- stats = engine.process_batch(changes, dry_run);
393
+ stats = engine.process_batch(sanitizedChanges, dry_run);
352
394
  } catch (e: any) {
353
395
  if (e instanceof BatchValidationError) {
354
396
  return {
@@ -706,8 +748,11 @@ export function formatBatchResult(
706
748
  } else {
707
749
  res = `Batch complete. Saved to: ${outPath}\n`;
708
750
  }
751
+ const total_occurrences = stats.edits ? stats.edits.reduce((acc: number, e: any) => acc + (e.status === "applied" ? (e.occurrences_modified || 1) : 0), 0) : 0;
752
+ const occ_text = total_occurrences > stats.edits_applied ? ` (${total_occurrences} occurrences)` : "";
753
+
709
754
  res += `Actions: ${stats.actions_applied} applied, ${stats.actions_skipped} skipped.\n`;
710
- res += `Edits: ${stats.edits_applied} applied, ${stats.edits_skipped} skipped.\n`;
755
+ res += `Edits: ${stats.edits_applied} applied${occ_text}, ${stats.edits_skipped} skipped.\n`;
711
756
 
712
757
  if (stats.edits && stats.edits.length > 0) {
713
758
  res += "\nDetailed Edit Reports:\n";
@@ -715,28 +760,43 @@ export function formatBatchResult(
715
760
  const report = stats.edits[i];
716
761
  const status_indicator =
717
762
  report.status === "applied" ? "✅ [applied]" : "❌ [failed]";
718
- res += `Edit ${i + 1} ${status_indicator}:\n`;
719
- res += ` Target: '${report.target_text}'\n`;
720
- res += ` New text: '${report.new_text}'\n`;
721
- if (report.warning) {
722
- res += ` Warning: ${report.warning}\n`;
763
+
764
+ const pagesStr = report.pages && report.pages.length > 0
765
+ ? ` (p${report.pages.join(", p")})`
766
+ : "";
767
+
768
+ res += `### Edit ${i + 1} ${status_indicator}${pagesStr}\n`;
769
+
770
+ if (report.heading_path) {
771
+ res += `**Path:** \`${report.heading_path}\`\n`;
723
772
  }
773
+
774
+ if (report.match_mode) {
775
+ const occ = report.occurrences_modified || (report.status === "applied" ? 1 : 0);
776
+ res += `**Mode:** \`${report.match_mode}\` (${occ} occurrence${occ !== 1 ? 's' : ''} modified)\n`;
777
+ }
778
+
724
779
  if (report.error) {
725
- res += ` Error: ${report.error}\n`;
780
+ res += `*Error:* ${report.error}\n`;
781
+ }
782
+ if (report.warning) {
783
+ res += `*Warning:* ${report.warning}\n`;
726
784
  }
785
+
727
786
  if (report.critic_markup) {
728
- res += ` Preview (CriticMarkup): ${report.critic_markup}\n`;
787
+ res += `*Preview (CriticMarkup):*\n> ${report.critic_markup.split('\\n').join('\\n> ')}\n`;
729
788
  }
730
789
  if (report.clean_text) {
731
- res += ` Clean text preview: ${report.clean_text}\n`;
790
+ res += `*Preview (Clean):*\n> ${report.clean_text.split('\\n').join('\\n> ')}\n`;
732
791
  }
792
+ res += "\n";
733
793
  }
734
794
  }
735
795
 
736
796
  if (stats.skipped_details && stats.skipped_details.length > 0) {
737
- res += `\n\nSkipped Details:\n${stats.skipped_details.join("\n")}`;
797
+ res += `Skipped Details:\n${stats.skipped_details.join("\n")}`;
738
798
  }
739
- return res;
799
+ return res.trim();
740
800
  }
741
801
 
742
802
  // --- Startup ---
@@ -161,4 +161,32 @@ describe("Resolved Bugs MCP Server Verification", () => {
161
161
  expect(res.result.content[0].text).toContain("uv tool install adeu");
162
162
  expect(res.result.content[0].text).not.toContain("ENOENT"); // Raw node error must not leak
163
163
  });
164
+
165
+ it("Double-Serialization: process_document_batch fails with TypeError when changes array contains double-serialized JSON strings", async () => {
166
+ const res = await sendRpc(
167
+ "tools/call",
168
+ {
169
+ name: "process_document_batch",
170
+ arguments: {
171
+ original_docx_path: cleanDocPath,
172
+ author_name: "Agent",
173
+ changes: [
174
+ JSON.stringify({
175
+ type: "modify",
176
+ target_text: "document",
177
+ new_text: "clean document",
178
+ }),
179
+ ],
180
+ dry_run: true,
181
+ },
182
+ },
183
+ 105,
184
+ );
185
+
186
+ // On unpatched code, the tool catches the TypeError and returns it inside a standard MCP error response, causing this test to fail.
187
+ // On patched code, the tool successfully parses and applies the double-serialized JSON strings, returning a successful response.
188
+ expect(res.result.isError).toBeUndefined();
189
+ expect(res.result.content[0].text).toContain("Dry-run simulation complete.");
190
+ });
164
191
  });
192
+
@@ -208,3 +208,154 @@ export function build_appendix_response(
208
208
  },
209
209
  };
210
210
  }
211
+
212
+ export function build_search_response(
213
+ text: string,
214
+ search_query: string,
215
+ search_regex: boolean,
216
+ search_case_sensitive: boolean,
217
+ page: number | string,
218
+ file_path: string,
219
+ ): ToolResult {
220
+ const [body] = split_structural_appendix(text);
221
+ const escapeRegExp = (s: string) =>
222
+ s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
223
+ const flags = search_case_sensitive ? "g" : "gi";
224
+ const patternStr = search_regex ? search_query : escapeRegExp(search_query);
225
+
226
+ let regex: RegExp;
227
+ try {
228
+ regex = new RegExp(patternStr, flags);
229
+ } catch (e: any) {
230
+ throw new Error(`Invalid regex pattern: ${e.message}`);
231
+ }
232
+
233
+ const matches = Array.from(body.matchAll(regex));
234
+
235
+ if (matches.length === 0) {
236
+ const ui_markdown = `> **Search Results** — No matches found for query \`${search_query}\` in \`${basename(file_path)}\`.\n\nVerify your search spelling, or try setting \`search_case_sensitive\` to false or enabling \`search_regex\` if you used pattern wildcards.`;
237
+ const llm_content = `> **File Path:** \`${resolve(file_path)}\`\n\n${ui_markdown}`;
238
+ return {
239
+ content: [{ type: "text", text: llm_content }],
240
+ structuredContent: {
241
+ markdown: ui_markdown,
242
+ title: `Search: ${basename(file_path)}`,
243
+ file_path: resolve(file_path),
244
+ },
245
+ };
246
+ }
247
+
248
+ const pag_res = paginate(body, "");
249
+ const page_offsets = pag_res.body_page_offsets;
250
+ const total_matches = matches.length;
251
+ const total_pages = Math.ceil(total_matches / 10);
252
+
253
+ let start_idx = 0;
254
+ let end_idx = total_matches;
255
+ let page_text = "all";
256
+
257
+ const pageStr = String(page).toLowerCase();
258
+ if (pageStr !== "all") {
259
+ const page_num = parseInt(pageStr, 10);
260
+ if (isNaN(page_num) || page_num < 1 || page_num > total_pages) {
261
+ throw new Error(`Page ${page} out of range (search has ${total_pages} pages).`);
262
+ }
263
+ start_idx = (page_num - 1) * 10;
264
+ end_idx = Math.min(start_idx + 10, total_matches);
265
+ page_text = `${page_num} of ${total_pages}`;
266
+ }
267
+
268
+ const page_matches = matches.slice(start_idx, end_idx);
269
+
270
+ const ui_parts: string[] = [
271
+ `> **Search Results** — Found ${total_matches} matches for query \`${search_query}\` in \`${basename(file_path)}\`.`,
272
+ ];
273
+
274
+ if (total_pages > 1 && pageStr !== "all") {
275
+ const nextPage = parseInt(pageStr, 10) + 1;
276
+ ui_parts.push(`> Showing page ${page_text} (matches ${start_idx + 1}-${end_idx}). To see more matches, call \`read_docx\` with \`search_query='${search_query}'\`, \`search_regex=${search_regex ? "true" : "false"}\`, and \`page=${nextPage}\`.`);
277
+ }
278
+
279
+ const occurrences_map: Record<string, number> = {};
280
+ for (const m of matches) {
281
+ const matched_str = m[0];
282
+ occurrences_map[matched_str] = (occurrences_map[matched_str] || 0) + 1;
283
+ }
284
+
285
+ function get_heading(idx: number, txt: string): string {
286
+ const txtBefore = txt.substring(0, idx);
287
+ const lines = txtBefore.split("\n");
288
+ const path: string[] = [];
289
+ let current_level = 999;
290
+
291
+ for (let i = lines.length - 1; i >= 0; i--) {
292
+ const line = lines[i];
293
+ const m = line.match(/^(#{1,6})\s+(.*)/);
294
+ if (m) {
295
+ const level = m[1].length;
296
+ if (level < current_level) {
297
+ let cleanHeading = m[2].replace(/\*\*|__|[*_]/g, "").replace(/\{#[^}]+\}/g, "").trim();
298
+ if (cleanHeading.length > 80) {
299
+ cleanHeading = cleanHeading.substring(0, 80) + "...";
300
+ }
301
+ path.unshift(cleanHeading);
302
+ current_level = level;
303
+ if (level === 1) break;
304
+ }
305
+ }
306
+ }
307
+ return path.join(" > ");
308
+ }
309
+
310
+ let i = start_idx + 1;
311
+ for (const m of page_matches) {
312
+ const matched_str = m[0];
313
+ const m_start = m.index!;
314
+ const m_end = m_start + matched_str.length;
315
+
316
+ let p_num = 1;
317
+ for (let j = 0; j < page_offsets.length; j++) {
318
+ if (m_start >= page_offsets[j]) {
319
+ p_num = j + 1;
320
+ } else {
321
+ break;
322
+ }
323
+ }
324
+
325
+ const snippet_start = Math.max(0, m_start - 100);
326
+ const snippet_end = Math.min(body.length, m_end + 100);
327
+ const snippet = body.substring(snippet_start, m_start) + `**${matched_str}**` + body.substring(m_end, snippet_end);
328
+
329
+ const snippet_lines = snippet
330
+ .split("\n")
331
+ .filter((line) => line.trim().length > 0)
332
+ .map((line) => `> ${line}`)
333
+ .join("\n");
334
+
335
+ ui_parts.push("---");
336
+ ui_parts.push(`### Match ${i} (p${p_num})`);
337
+
338
+ const h_path = get_heading(m_start, body);
339
+ if (h_path) {
340
+ ui_parts.push(`**Path:** \`${h_path}\``);
341
+ }
342
+
343
+ const count = occurrences_map[matched_str];
344
+ ui_parts.push(snippet_lines);
345
+ ui_parts.push(`*Occurrences:* This exact phrasing appears ${count} time${count !== 1 ? "s" : ""} in the document.`);
346
+
347
+ i++;
348
+ }
349
+
350
+ const ui_markdown = ui_parts.join("\n\n");
351
+ const llm_content = `> **File Path:** \`${resolve(file_path)}\`\n\n${ui_markdown}`;
352
+
353
+ return {
354
+ content: [{ type: "text", text: llm_content }],
355
+ structuredContent: {
356
+ markdown: ui_markdown,
357
+ title: `Search: ${basename(file_path)}`,
358
+ file_path: resolve(file_path),
359
+ },
360
+ };
361
+ }