@adeu/mcp-server 1.14.0 → 1.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,17 +225,29 @@ 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.string()])
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
- .number()
232
+ .coerce.number()
232
233
  .default(2)
233
234
  .describe("For mode='outline' only: cap on heading depth."),
234
235
  outline_verbose: z
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 {
@@ -598,10 +618,10 @@ registerAppTool(
598
618
  has_attachments: z.boolean().optional(),
599
619
  attachment_name: z.string().optional(),
600
620
  is_unread: z.boolean().optional(),
601
- days_ago: z.number().optional(),
621
+ days_ago: z.coerce.number().optional(),
602
622
  folder: z.enum(["inbox", "sent", "all"]).optional(),
603
- limit: z.number().default(10),
604
- offset: z.number().default(0),
623
+ limit: z.coerce.number().default(10),
624
+ offset: z.coerce.number().default(0),
605
625
  email_id: z.string().optional(),
606
626
  working_directory: z.string().optional(),
607
627
  mailbox_address: z
@@ -613,7 +633,7 @@ registerAppTool(
613
633
  .optional()
614
634
  .describe("If resuming a pending check, provide the task ID here."),
615
635
  max_attachment_size_mb: z
616
- .number()
636
+ .coerce.number()
617
637
  .optional()
618
638
  .describe(
619
639
  "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.",
@@ -728,8 +748,11 @@ export function formatBatchResult(
728
748
  } else {
729
749
  res = `Batch complete. Saved to: ${outPath}\n`;
730
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
+
731
754
  res += `Actions: ${stats.actions_applied} applied, ${stats.actions_skipped} skipped.\n`;
732
- 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`;
733
756
 
734
757
  if (stats.edits && stats.edits.length > 0) {
735
758
  res += "\nDetailed Edit Reports:\n";
@@ -737,28 +760,43 @@ export function formatBatchResult(
737
760
  const report = stats.edits[i];
738
761
  const status_indicator =
739
762
  report.status === "applied" ? "✅ [applied]" : "❌ [failed]";
740
- res += `Edit ${i + 1} ${status_indicator}:\n`;
741
- res += ` Target: '${report.target_text}'\n`;
742
- res += ` New text: '${report.new_text}'\n`;
743
- if (report.warning) {
744
- 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`;
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`;
745
777
  }
778
+
746
779
  if (report.error) {
747
- res += ` Error: ${report.error}\n`;
780
+ res += `*Error:* ${report.error}\n`;
781
+ }
782
+ if (report.warning) {
783
+ res += `*Warning:* ${report.warning}\n`;
748
784
  }
785
+
749
786
  if (report.critic_markup) {
750
- res += ` Preview (CriticMarkup): ${report.critic_markup}\n`;
787
+ res += `*Preview (CriticMarkup):*\n> ${report.critic_markup.split('\\n').join('\\n> ')}\n`;
751
788
  }
752
789
  if (report.clean_text) {
753
- res += ` Clean text preview: ${report.clean_text}\n`;
790
+ res += `*Preview (Clean):*\n> ${report.clean_text.split('\\n').join('\\n> ')}\n`;
754
791
  }
792
+ res += "\n";
755
793
  }
756
794
  }
757
795
 
758
796
  if (stats.skipped_details && stats.skipped_details.length > 0) {
759
- res += `\n\nSkipped Details:\n${stats.skipped_details.join("\n")}`;
797
+ res += `Skipped Details:\n${stats.skipped_details.join("\n")}`;
760
798
  }
761
- return res;
799
+ return res.trim();
762
800
  }
763
801
 
764
802
  // --- Startup ---
@@ -188,5 +188,26 @@ describe("Resolved Bugs MCP Server Verification", () => {
188
188
  expect(res.result.isError).toBeUndefined();
189
189
  expect(res.result.content[0].text).toContain("Dry-run simulation complete.");
190
190
  });
191
- });
192
191
 
192
+ it("BUG-12: Accepts stringified numbers for numeric arguments without Zod validation errors", async () => {
193
+ const res = await sendRpc(
194
+ "tools/call",
195
+ {
196
+ name: "read_docx",
197
+ arguments: {
198
+ file_path: cleanDocPath,
199
+ page: "1",
200
+ outline_max_level: "3",
201
+ },
202
+ },
203
+ 106,
204
+ );
205
+
206
+ // If Zod validation failed, we would get an error payload back or `res.error`
207
+ // from the MCP protocol (error code -32602).
208
+ expect(res.error).toBeUndefined();
209
+ expect(res.result).toBeDefined();
210
+ expect(res.result.isError).toBeUndefined();
211
+ expect(res.result.content[0].text).toContain("golden");
212
+ });
213
+ });
@@ -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
+ }