@adeu/mcp-server 1.18.1 → 1.18.4

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.
@@ -4,9 +4,17 @@ import { spawn, ChildProcess } from "node:child_process";
4
4
  import { resolve, join } from "node:path";
5
5
  import { existsSync, readFileSync } from "node:fs";
6
6
 
7
+ // Each Python CLI invocation spawns a fresh interpreter costing ~3-5s on
8
+ // Windows even when warm, so tests that shell out to Python need more than
9
+ // vitest's 5s default timeout.
10
+ const PYTHON_PARITY_TIMEOUT_MS = 60_000;
11
+
7
12
  describe("Parity Live Server Integration Verification", () => {
8
13
  let serverProc: ChildProcess;
9
- const fixturePath = resolve(__dirname, "../tests/fixtures/gap2_minimal_repro.docx");
14
+ const fixturePath = resolve(
15
+ __dirname,
16
+ "../tests/fixtures/gap2_minimal_repro.docx",
17
+ );
10
18
 
11
19
  beforeAll(async () => {
12
20
  const serverPath = resolve(__dirname, "../dist/index.js");
@@ -76,86 +84,117 @@ describe("Parity Live Server Integration Verification", () => {
76
84
  });
77
85
 
78
86
  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);
87
+ const initRes = await sendRpc(
88
+ "initialize",
89
+ {
90
+ protocolVersion: "2024-11-05",
91
+ capabilities: {},
92
+ clientInfo: { name: "test-client", version: "1.0.0" },
93
+ },
94
+ 202,
95
+ );
84
96
  expect(initRes.result).toBeDefined();
85
97
  expect(initRes.result.serverInfo).toBeDefined();
86
98
  expect(initRes.result.serverInfo.version).not.toBe("1.0.0");
87
99
  expect(initRes.result.serverInfo.version).not.toContain("unknown");
88
100
  });
89
101
 
90
-
91
102
  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",
103
+ const res = await sendRpc(
104
+ "tools/call",
105
+ {
106
+ name: "read_docx",
107
+ arguments: {
108
+ reasoning: "test",
109
+ file_path: fixturePath,
110
+ mode: "outline",
111
+ },
97
112
  },
98
- }, 202);
113
+ 202,
114
+ );
99
115
 
100
116
  expect(res.result).toBeDefined();
101
117
  expect(res.result.content[0].text).not.toContain("[Debug] build=");
102
118
  });
103
119
 
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,
120
+ it("GAP 2 (Live): process_document_batch modify straddling deleted text returns actionable deletion error", { timeout: PYTHON_PARITY_TIMEOUT_MS }, async () => {
121
+ const outPath = join(
122
+ resolve(__dirname, "../../../../tmp"),
123
+ `live_gap2_out_${Date.now()}.docx`,
124
+ );
125
+ const res = await sendRpc(
126
+ "tools/call",
127
+ {
128
+ name: "process_document_batch",
129
+ arguments: {
130
+ reasoning: "test",
131
+ original_docx_path: fixturePath,
132
+ author_name: "Ed",
133
+ changes: [
134
+ {
135
+ type: "modify",
136
+ target_text: "Foo bar old phrase here.",
137
+ new_text: "X",
138
+ comment: "c",
139
+ },
140
+ ],
141
+ output_path: outPath,
142
+ dry_run: true,
143
+ },
121
144
  },
122
- }, 203);
145
+ 203,
146
+ );
123
147
 
124
148
  expect(res.result).toBeDefined();
125
149
  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.");
150
+ expect(text).toContain(
151
+ "Edit 1 Failed: Target text matches text inside a tracked deletion by Test Negotiator.",
152
+ );
153
+ expect(text).toContain(
154
+ "Reject/accept that change first or target the active replacement text instead.",
155
+ );
128
156
  expect(text).not.toContain("Target text not found in document.");
129
157
 
130
158
  // Run Python engine to assert cross-engine exact error parity
131
159
  const { execSync } = await import("node:child_process");
132
160
  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
-
161
+ const pythonCli =
162
+ process.platform === "win32"
163
+ ? join(projectRoot, "python/.venv/Scripts/adeu.exe")
164
+ : join(projectRoot, "python/.venv/bin/adeu");
165
+
137
166
  // Create temporary changes JSON for Python CLI
138
167
  const fs = await import("node:fs");
139
168
  const tempJsonPath = join(projectRoot, "tmp", `changes_${Date.now()}.json`);
140
169
  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
- ]));
170
+ fs.writeFileSync(
171
+ tempJsonPath,
172
+ JSON.stringify([
173
+ {
174
+ type: "modify",
175
+ target_text: "Foo bar old phrase here.",
176
+ new_text: "X",
177
+ comment: "c",
178
+ },
179
+ ]),
180
+ );
149
181
 
150
182
  try {
151
- const pythonOut = execSync(`"${pythonCli}" apply "${fixturePath}" "${tempJsonPath}" --dry-run --author Ed`, {
152
- encoding: "utf-8",
153
- });
183
+ const pythonOut = execSync(
184
+ `"${pythonCli}" apply "${fixturePath}" "${tempJsonPath}" --dry-run --author Ed`,
185
+ {
186
+ encoding: "utf-8",
187
+ },
188
+ );
154
189
  // Should not succeed normally since dry run exits with code 1 on errors, so we catch in the try block
155
190
  } catch (err: any) {
156
191
  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.");
192
+ expect(pyErrorOutput).toContain(
193
+ "Edit 1 Failed: Target text matches text inside a tracked deletion by Test Negotiator.",
194
+ );
195
+ expect(pyErrorOutput).toContain(
196
+ "Reject/accept that change first or target the active replacement text instead.",
197
+ );
159
198
  } finally {
160
199
  if (fs.existsSync(tempJsonPath)) {
161
200
  fs.unlinkSync(tempJsonPath);
@@ -163,35 +202,51 @@ describe("Parity Live Server Integration Verification", () => {
163
202
  }
164
203
  });
165
204
 
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");
205
+ it("GAP 1 (Live): read_docx mode=outline heading count and content parity with Python", { timeout: PYTHON_PARITY_TIMEOUT_MS }, async () => {
206
+ const gap1FixturePath = resolve(
207
+ __dirname,
208
+ "../tests/fixtures/gap1_deleted_row_repro.docx",
209
+ );
168
210
  const { execSync } = await import("node:child_process");
169
211
  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");
212
+ const pythonCli =
213
+ process.platform === "win32"
214
+ ? join(projectRoot, "python/.venv/Scripts/adeu.exe")
215
+ : join(projectRoot, "python/.venv/bin/adeu");
173
216
 
174
217
  // 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,
218
+ const nodeResClean = await sendRpc(
219
+ "tools/call",
220
+ {
221
+ name: "read_docx",
222
+ arguments: {
223
+ reasoning: "test",
224
+ file_path: gap1FixturePath,
225
+ mode: "outline",
226
+ clean_view: true,
227
+ },
181
228
  },
182
- }, 204);
229
+ 204,
230
+ );
183
231
 
184
232
  expect(nodeResClean.result).toBeDefined();
185
233
  const nodeTextClean = nodeResClean.result.content[0].text;
186
-
234
+
187
235
  // Get Python output for clean_view = true
188
- const pythonOutClean = execSync(`"${pythonCli}" extract "${gap1FixturePath}" --mode outline --clean-view`, {
189
- encoding: "utf-8",
190
- });
236
+ const pythonOutClean = execSync(
237
+ `"${pythonCli}" extract "${gap1FixturePath}" --mode outline --clean-view`,
238
+ {
239
+ encoding: "utf-8",
240
+ },
241
+ );
191
242
 
192
243
  // 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
-
244
+ const getHeadings = (text: string) =>
245
+ text
246
+ .split("\n")
247
+ .filter((line) => line.startsWith("#"))
248
+ .map((l) => l.trim());
249
+
195
250
  const nodeHeadingsClean = getHeadings(nodeTextClean);
196
251
  const pythonHeadingsClean = getHeadings(pythonOutClean);
197
252
 
@@ -200,56 +255,86 @@ describe("Parity Live Server Integration Verification", () => {
200
255
  expect(nodeHeadingsClean).toEqual(pythonHeadingsClean);
201
256
 
202
257
  // 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,
258
+ const nodeResDirty = await sendRpc(
259
+ "tools/call",
260
+ {
261
+ name: "read_docx",
262
+ arguments: {
263
+ reasoning: "test",
264
+ file_path: gap1FixturePath,
265
+ mode: "outline",
266
+ clean_view: false,
267
+ },
209
268
  },
210
- }, 205);
269
+ 205,
270
+ );
211
271
 
212
272
  expect(nodeResDirty.result).toBeDefined();
213
273
  const nodeTextDirty = nodeResDirty.result.content[0].text;
214
274
 
215
- const pythonOutDirty = execSync(`"${pythonCli}" extract "${gap1FixturePath}" --mode outline`, {
216
- encoding: "utf-8",
217
- });
275
+ const pythonOutDirty = execSync(
276
+ `"${pythonCli}" extract "${gap1FixturePath}" --mode outline`,
277
+ {
278
+ encoding: "utf-8",
279
+ },
280
+ );
218
281
 
219
282
  const nodeHeadingsDirty = getHeadings(nodeTextDirty);
220
283
  const pythonHeadingsDirty = getHeadings(pythonOutDirty);
221
284
 
222
- expect(nodeHeadingsDirty).toEqual(["# Active Heading (p1)", "# Deleted Heading (p1)"]);
223
- expect(pythonHeadingsDirty).toEqual(["# Active Heading (p1)", "# Deleted Heading (p1)"]);
285
+ expect(nodeHeadingsDirty).toEqual([
286
+ "# Active Heading (p1)",
287
+ "# Deleted Heading (p1)",
288
+ ]);
289
+ expect(pythonHeadingsDirty).toEqual([
290
+ "# Active Heading (p1)",
291
+ "# Deleted Heading (p1)",
292
+ ]);
224
293
  expect(nodeHeadingsDirty).toEqual(pythonHeadingsDirty);
225
294
  });
226
295
 
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");
296
+ it("GAP 1 - Style Def (Live): style-definition outlineLvl on non-heading style classification parity", { timeout: PYTHON_PARITY_TIMEOUT_MS }, async () => {
297
+ const gap1FixturePath = resolve(
298
+ __dirname,
299
+ "../tests/fixtures/gap1_minimal_repro.docx",
300
+ );
229
301
  const { execSync } = await import("node:child_process");
230
302
  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");
303
+ const pythonCli =
304
+ process.platform === "win32"
305
+ ? join(projectRoot, "python/.venv/Scripts/adeu.exe")
306
+ : join(projectRoot, "python/.venv/bin/adeu");
234
307
 
235
308
  // 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,
309
+ const nodeResClean = await sendRpc(
310
+ "tools/call",
311
+ {
312
+ name: "read_docx",
313
+ arguments: {
314
+ reasoning: "test",
315
+ file_path: gap1FixturePath,
316
+ mode: "outline",
317
+ clean_view: true,
318
+ },
242
319
  },
243
- }, 206);
320
+ 206,
321
+ );
244
322
 
245
323
  expect(nodeResClean.result).toBeDefined();
246
324
  const nodeTextClean = nodeResClean.result.content[0].text;
247
325
 
248
- const pythonOutClean = execSync(`"${pythonCli}" extract "${gap1FixturePath}" --mode outline --clean-view`, {
249
- encoding: "utf-8",
250
- });
326
+ const pythonOutClean = execSync(
327
+ `"${pythonCli}" extract "${gap1FixturePath}" --mode outline --clean-view`,
328
+ {
329
+ encoding: "utf-8",
330
+ },
331
+ );
251
332
 
252
- const getHeadings = (text: string) => text.split("\n").filter(line => line.startsWith("#")).map(l => l.trim());
333
+ const getHeadings = (text: string) =>
334
+ text
335
+ .split("\n")
336
+ .filter((line) => line.startsWith("#"))
337
+ .map((l) => l.trim());
253
338
 
254
339
  const nodeHeadingsClean = getHeadings(nodeTextClean);
255
340
  const pythonHeadingsClean = getHeadings(pythonOutClean);
@@ -258,8 +343,14 @@ describe("Parity Live Server Integration Verification", () => {
258
343
  expect(nodeHeadingsClean.length).toBe(2);
259
344
  expect(pythonHeadingsClean.length).toBe(2);
260
345
 
261
- expect(nodeHeadingsClean).toEqual(["# Real Heading One (p1)", "# Real Heading Two (p1)"]);
262
- expect(pythonHeadingsClean).toEqual(["# Real Heading One (p1)", "# Real Heading Two (p1)"]);
346
+ expect(nodeHeadingsClean).toEqual([
347
+ "# Real Heading One (p1)",
348
+ "# Real Heading Two (p1)",
349
+ ]);
350
+ expect(pythonHeadingsClean).toEqual([
351
+ "# Real Heading One (p1)",
352
+ "# Real Heading Two (p1)",
353
+ ]);
263
354
  expect(nodeHeadingsClean).toEqual(pythonHeadingsClean);
264
355
  });
265
356
  });
@@ -57,14 +57,17 @@ describe("Field feedback repro — Issue 3 file-path ambiguity (real MCP server)
57
57
 
58
58
  const serverPath = resolve(__dirname, "../dist/index.js");
59
59
  if (!existsSync(serverPath)) {
60
- throw new Error("MCP server not built. Run 'npm run build' before tests.");
60
+ throw new Error(
61
+ "MCP server not built. Run 'npm run build' before tests.",
62
+ );
61
63
  }
62
64
  serverProc = spawn("node", [serverPath]);
63
65
  });
64
66
 
65
67
  afterAll(() => {
66
68
  if (serverProc && !serverProc.killed) serverProc.kill();
67
- if (workDir && existsSync(workDir)) rmSync(workDir, { recursive: true, force: true });
69
+ if (workDir && existsSync(workDir))
70
+ rmSync(workDir, { recursive: true, force: true });
68
71
  });
69
72
 
70
73
  function sendRpc(method: string, params: any, id: number): Promise<any> {
@@ -105,9 +108,12 @@ describe("Field feedback repro — Issue 3 file-path ambiguity (real MCP server)
105
108
  const r1 = await callTool(
106
109
  "process_document_batch",
107
110
  {
111
+ reasoning: "test",
108
112
  original_docx_path: contractPath,
109
113
  author_name: "Agent",
110
- changes: [{ type: "modify", target_text: "Provider", new_text: "Supplier" }],
114
+ changes: [
115
+ { type: "modify", target_text: "Provider", new_text: "Supplier" },
116
+ ],
111
117
  },
112
118
  201,
113
119
  );
@@ -120,7 +126,10 @@ describe("Field feedback repro — Issue 3 file-path ambiguity (real MCP server)
120
126
  // NOT back onto contract.docx / contract_processed.docx.
121
127
  const r2 = await callTool(
122
128
  "accept_all_changes",
123
- { docx_path: join(workDir, "contract_processed.docx") },
129
+ {
130
+ reasoning: "test",
131
+ docx_path: join(workDir, "contract_processed.docx"),
132
+ },
124
133
  202,
125
134
  );
126
135
  const msg2 = r2.result.content[0].text as string;
@@ -145,15 +154,20 @@ describe("Field feedback repro — Issue 3 file-path ambiguity (real MCP server)
145
154
  const r = await callTool(
146
155
  "process_document_batch",
147
156
  {
157
+ reasoning: "test",
148
158
  original_docx_path: join(workDir, "contract_processed.docx"),
149
159
  author_name: "Agent",
150
- changes: [{ type: "modify", target_text: "goods", new_text: "products" }],
160
+ changes: [
161
+ { type: "modify", target_text: "goods", new_text: "products" },
162
+ ],
151
163
  },
152
164
  203,
153
165
  );
154
166
  const msg = r.result.content[0].text as string;
155
167
  expect(savedFile(msg)).toBe("contract_processed.docx");
156
- expect(existsSync(join(workDir, "contract_processed_processed.docx"))).toBe(false);
168
+ expect(existsSync(join(workDir, "contract_processed_processed.docx"))).toBe(
169
+ false,
170
+ );
157
171
  });
158
172
 
159
173
  it("Issue 1 (end-to-end via shipped server): accept + modify the same foreign insertion in one batch succeeds", async () => {
@@ -162,7 +176,9 @@ describe("Field feedback repro — Issue 3 file-path ambiguity (real MCP server)
162
176
  const foreign = join(workDir, "lease.docx");
163
177
  {
164
178
  const doc = await DocumentObject.load(
165
- readFileSync(resolve(__dirname, "../../../../shared/fixtures/initial.docx")),
179
+ readFileSync(
180
+ resolve(__dirname, "../../../../shared/fixtures/initial.docx"),
181
+ ),
166
182
  );
167
183
  const body = doc.element;
168
184
  while (body.firstChild) body.removeChild(body.firstChild);
@@ -191,6 +207,7 @@ describe("Field feedback repro — Issue 3 file-path ambiguity (real MCP server)
191
207
  const res = await callTool(
192
208
  "process_document_batch",
193
209
  {
210
+ reasoning: "test",
194
211
  original_docx_path: foreign,
195
212
  author_name: "Acme's Counsel",
196
213
  output_path: join(workDir, "lease_out.docx"),
@@ -220,13 +220,27 @@ export function build_search_response(
220
220
  const [body] = split_structural_appendix(text);
221
221
  const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
222
222
  const flags = search_case_sensitive ? "g" : "gi";
223
- const patternStr = search_regex ? search_query : escapeRegExp(search_query);
224
223
 
224
+ // When the caller asked for a regex but supplied something the engine can't
225
+ // compile (e.g. an unterminated character class `\[`, or an inline-flag group
226
+ // `(?i)...` that JS RegExp rejects), do NOT hard-error and burn the turn.
227
+ // Downgrade to a literal search of the raw string and tell the model, so it
228
+ // can either accept the literal hits or fix its pattern — instead of retrying
229
+ // the same broken regex.
230
+ let regexDowngradedNote = "";
225
231
  let regex: RegExp;
226
- try {
227
- regex = new RegExp(patternStr, flags);
228
- } catch (e: any) {
229
- throw new Error(`Invalid regex pattern: ${e.message}`);
232
+ if (search_regex) {
233
+ try {
234
+ regex = new RegExp(search_query, flags);
235
+ } catch (e: any) {
236
+ regexDowngradedNote =
237
+ `> **Note:** \`${search_query}\` is not a valid regular expression ` +
238
+ `(${e.message}), so it was searched as literal text instead. ` +
239
+ `If you meant a regex, fix the pattern; if you meant literal text, set \`search_regex\` to false.`;
240
+ regex = new RegExp(escapeRegExp(search_query), flags);
241
+ }
242
+ } else {
243
+ regex = new RegExp(escapeRegExp(search_query), flags);
230
244
  }
231
245
 
232
246
  const allMatches = Array.from(body.matchAll(regex));
@@ -295,6 +309,7 @@ export function build_search_response(
295
309
  } else {
296
310
  body_msg = `> **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.`;
297
311
  }
312
+ if (regexDowngradedNote) body_msg = `${regexDowngradedNote}\n\n${body_msg}`;
298
313
  const llm_content = `> **File Path:** \`${resolve(file_path)}\`\n\n${body_msg}`;
299
314
  return {
300
315
  content: [{ type: "text", text: llm_content }],
@@ -415,6 +430,7 @@ export function build_search_response(
415
430
  i++;
416
431
  }
417
432
 
433
+ if (regexDowngradedNote) ui_parts.unshift(regexDowngradedNote);
418
434
  const ui_markdown = ui_parts.join("\n\n");
419
435
  const llm_content = `> **File Path:** \`${resolve(file_path)}\`\n\n${ui_markdown}`;
420
436