@adeu/mcp-server 1.15.2 → 1.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/mcp-server",
3
- "version": "1.15.2",
3
+ "version": "1.16.0",
4
4
  "description": "",
5
5
  "mcpName": "ai.adeu/adeu",
6
6
  "main": "./dist/index.js",
@@ -31,7 +31,7 @@
31
31
  "license": "MIT",
32
32
  "type": "module",
33
33
  "dependencies": {
34
- "@adeu/core": "^1.15.2",
34
+ "@adeu/core": "^1.16.0",
35
35
  "@modelcontextprotocol/sdk": "^1.0.0",
36
36
  "@modelcontextprotocol/ext-apps": "^1.0.0",
37
37
  "zod": "^3.23.8"
package/src/index.ts CHANGED
@@ -83,10 +83,10 @@ const READ_DOCX_TAIL =
83
83
  const PROCESS_BATCH_COMMON_DESC =
84
84
  "Applies a batch of edits and review actions to a DOCX.\n\nAll changes evaluate against the ORIGINAL document state — do not chain dependent edits within one batch (e.g. rename X to Y, then modify Y). Apply the rename first, then send a second batch.\n\n";
85
85
  const PROCESS_BATCH_OPERATIONS_DESC =
86
- "Each item in `changes` must specify a `type`:\n1. 'modify': Search-and-replace. `target_text` must uniquely matchinclude surrounding context if the phrase is ambiguous. `new_text` supports Markdown: '# Heading 1' through '###### Heading 6', '**bold**', '_italic_', and '\\n\\n' to split into multiple paragraphs. Empty `new_text` deletes. Do NOT write CriticMarkup tags ({++, {--, {>>) manually — use the `comment` parameter for comments.\n2. 'accept' / 'reject': Finalize or revert a tracked change by `target_id` (e.g. 'Chg:12').\n3. 'reply': Reply to a comment by `target_id` (e.g. 'Com:5') with `text`.\n4. 'insert_row' / 'delete_row': Table edits. Disk mode only — not supported on Live Word canvas.\n\nID VOLATILITY: 'Chg:N' and 'Com:N' shift between document states. Always call `read_docx` immediately before any accept/reject/reply — do not reuse IDs from earlier in the conversation.\n\n`author_name` is used for attribution on all tracked changes and comments, in both disk and Live Word modes.";
86
+ "Each item in `changes` must specify a `type`:\n1. 'modify': Search-and-replace. By default `target_text` must match uniquely (`match_mode`:'strict')add surrounding context to disambiguate, or set `match_mode`:'first'/'all' to edit the first or every occurrence. Set `regex`:true to treat `target_text` as a regular expression (capture groups available in `new_text` as $1, $2…). `new_text` supports Markdown: '# Heading 1' through '###### Heading 6', '**bold**', '_italic_', and '\\n\\n' to split into multiple paragraphs. Empty `new_text` deletes. Do NOT write CriticMarkup tags ({++, {--, {>>) manually — use the `comment` parameter for comments.\n2. 'accept' / 'reject': Finalize or revert a tracked change by `target_id` (e.g. 'Chg:12').\n3. 'reply': Reply to a comment by `target_id` (e.g. 'Com:5') with `text`.\n4. 'insert_row' / 'delete_row': Table edits. Disk mode only — not supported on Live Word canvas.\n\nID VOLATILITY: 'Chg:N' and 'Com:N' shift between document states. Always call `read_docx` immediately before any accept/reject/reply — do not reuse IDs from earlier in the conversation.\n\n`author_name` is used for attribution on all tracked changes and comments, in both disk and Live Word modes.";
87
87
 
88
88
  const DIFF_DOCX_DESC =
89
- "Compares two DOCX files and returns a unified diff of their text content. Useful for analyzing differences between versions before editing.";
89
+ "Compares two DOCX files and returns a compact `@@ Word Patch @@` diff — Adeu's token-level, sub-word patch format — of their text content. Useful for analyzing differences between versions before editing.";
90
90
 
91
91
  const gitSha = process.env.GIT_SHA || "unknown";
92
92
  const packageVersion = process.env.PACKAGE_VERSION || "unknown";
@@ -110,7 +110,9 @@ const server = new McpServer({
110
110
  const originalRegisterTool = server.registerTool.bind(server);
111
111
  server.registerTool = (name: string, schema: any, handler?: any) => {
112
112
  if (schema && typeof schema === "object") {
113
- if (schema.description) {
113
+ // Idempotent: UI tools route through BOTH this wrapper and the
114
+ // registerAppTool wrapper, so guard against stamping the tag twice.
115
+ if (schema.description && !schema.description.includes(buildTag.trim())) {
114
116
  schema.description = schema.description.trim() + buildTag;
115
117
  }
116
118
  }
@@ -344,6 +346,69 @@ registerAppTool(
344
346
  // 3. HEADLESS TOOLS (No UI)
345
347
  // ==========================================
346
348
 
349
+ // Typed shape for a single `process_document_batch` change. This makes the six
350
+ // DocumentChange variants — and the modify-only `match_mode`/`regex` options —
351
+ // discoverable from the tool schema itself, instead of prose alone. A bare
352
+ // string is still accepted (and normalized in-handler) so double-serialized
353
+ // payloads from some LLM clients keep working; only `type` is required, all
354
+ // other fields are optional, and unknown keys pass through untouched.
355
+ const CHANGE_ITEM_SCHEMA = z
356
+ .object({
357
+ type: z
358
+ .enum(["modify", "accept", "reject", "reply", "insert_row", "delete_row"])
359
+ .describe(
360
+ "Change kind: 'modify' (search-and-replace), 'accept'/'reject' (resolve a tracked change by id), 'reply' (reply to a comment by id), 'insert_row'/'delete_row' (table edits; disk mode only).",
361
+ ),
362
+ target_text: z
363
+ .string()
364
+ .optional()
365
+ .describe(
366
+ "modify / insert_row / delete_row: the existing text to locate (interpreted as a regex when regex=true).",
367
+ ),
368
+ new_text: z
369
+ .string()
370
+ .optional()
371
+ .describe(
372
+ "modify: replacement text. Supports Markdown (headings, **bold**, _italic_, '\\n\\n' paragraph splits); empty string deletes. Regex capture groups are available as $1, $2…",
373
+ ),
374
+ target_id: z
375
+ .string()
376
+ .optional()
377
+ .describe(
378
+ "accept / reject / reply: the 'Chg:N' or 'Com:N' id taken from a fresh read_docx.",
379
+ ),
380
+ text: z.string().optional().describe("reply: the reply body."),
381
+ comment: z
382
+ .string()
383
+ .optional()
384
+ .describe(
385
+ "modify / accept / reject: attach a margin comment to the change (no manual CriticMarkup).",
386
+ ),
387
+ match_mode: z
388
+ .enum(["strict", "first", "all"])
389
+ .optional()
390
+ .describe(
391
+ "modify only: 'strict' (default — target must match uniquely), 'first' (first occurrence), or 'all' (every occurrence).",
392
+ ),
393
+ regex: z
394
+ .boolean()
395
+ .optional()
396
+ .describe(
397
+ "modify only: treat target_text as a regular expression (default false).",
398
+ ),
399
+ position: z
400
+ .enum(["above", "below"])
401
+ .optional()
402
+ .describe(
403
+ "insert_row: place the new row above or below the matched row.",
404
+ ),
405
+ cells: z
406
+ .array(z.string())
407
+ .optional()
408
+ .describe("insert_row: the cell values for the new row, left to right."),
409
+ })
410
+ .passthrough();
411
+
347
412
  server.registerTool(
348
413
  "process_document_batch",
349
414
  {
@@ -356,8 +421,10 @@ server.registerTool(
356
421
  .string()
357
422
  .describe("Name to appear in Track Changes (e.g., 'Reviewer AI')."),
358
423
  changes: z
359
- .array(z.any())
360
- .describe("List of changes to apply. Each change must specify 'type'."),
424
+ .array(z.union([z.string(), CHANGE_ITEM_SCHEMA]))
425
+ .describe(
426
+ "Ordered list of changes to apply. Each item is an object carrying a `type` discriminator plus that type's fields (see the per-field docs and the tool description). All items evaluate against the ORIGINAL document state.",
427
+ ),
361
428
  output_path: z.string().optional().describe("Optional output path."),
362
429
  dry_run: z
363
430
  .boolean()
@@ -549,7 +616,7 @@ server.registerTool(
549
616
  "finalize_document",
550
617
  {
551
618
  description:
552
- "Prepares a document for external distribution or e-signature.",
619
+ "Prepares a document for external distribution or e-signature. Note: in this zero-dependency environment, protection_mode='encrypt' is unsupported and falls back to a native read-only lock; export_pdf and password are ignored.",
553
620
  inputSchema: {
554
621
  file_path: z.string().describe("Absolute path to the DOCX file."),
555
622
  output_path: z.string().optional().describe("Optional output path."),
@@ -566,7 +633,9 @@ server.registerTool(
566
633
  protection_mode: z
567
634
  .enum(["read_only", "encrypt"])
568
635
  .optional()
569
- .describe("Native OOXML document locking."),
636
+ .describe(
637
+ "Native OOXML document locking. Note: 'encrypt' is unsupported in this zero-dependency build and falls back to 'read_only'.",
638
+ ),
570
639
  password: z.string().optional().describe("Ignored in this environment."),
571
640
  author: z
572
641
  .string()
@@ -189,6 +189,29 @@ describe("Resolved Bugs MCP Server Verification", () => {
189
189
  expect(res.result.content[0].text).toContain("Dry-run simulation complete.");
190
190
  });
191
191
 
192
+ it("Unparseable String: process_document_batch gracefully rejects raw strings instead of crashing", async () => {
193
+ const res = await sendRpc(
194
+ "tools/call",
195
+ {
196
+ name: "process_document_batch",
197
+ arguments: {
198
+ original_docx_path: cleanDocPath,
199
+ author_name: "Agent",
200
+ changes: [
201
+ "modify document to be clean document" // Raw unparseable string
202
+ ],
203
+ dry_run: false,
204
+ },
205
+ },
206
+ 110,
207
+ );
208
+
209
+ expect(res.result.isError).toBe(true);
210
+ expect(res.result.content[0].text).toContain("Batch rejected. Some edits failed validation");
211
+ expect(res.result.content[0].text).toContain("Invalid change format");
212
+ expect(res.result.content[0].text).toContain("received a primitive string");
213
+ });
214
+
192
215
  it("BUG-12: Accepts stringified numbers for numeric arguments without Zod validation errors", async () => {
193
216
  const res = await sendRpc(
194
217
  "tools/call",
@@ -0,0 +1,384 @@
1
+ // FILE: node/packages/mcp-server/src/mcp.schema-gaps.test.ts
2
+ //
3
+ // Guards the MCP boundary's HONESTY: what the server advertises over `tools/list`
4
+ // (the schema + documentation an LLM sees) must match what the tools really do.
5
+ // Each block below closed a gap where an agent, behaving exactly as documented,
6
+ // was either misled or kept from a capability that exists — "under-documented
7
+ // power is unused power." These tests now assert the corrected state and fail if
8
+ // any gap regresses.
9
+ //
10
+ // Gaps closed:
11
+ // • process_document_batch — `changes` now publishes a typed item schema so the
12
+ // six DocumentChange variants are discoverable (ADEU_TOOL_ISSUES #1), and the
13
+ // real `match_mode`/`regex` options are documented in both schema and prose
14
+ // (#10). Both are still proven honored at the live boundary.
15
+ // • read_docx — its description carries the build stamp exactly once (UI tools
16
+ // were previously double-wrapped and stamped twice).
17
+ // • diff_docx_files — described as the custom `@@ Word Patch @@` format it
18
+ // actually emits, no longer mislabeled a "unified diff".
19
+ // • finalize_document — discloses that `protection_mode:'encrypt'` is unsupported
20
+ // in the zero-dependency Node build and falls back to a read-only lock.
21
+
22
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
23
+ import { spawn, ChildProcess } from "node:child_process";
24
+ import { resolve, join } from "node:path";
25
+ import { tmpdir } from "node:os";
26
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
27
+ import { fileURLToPath } from "node:url";
28
+ import { DocumentObject } from "@adeu/core";
29
+
30
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
31
+
32
+ const CHANGE_VARIANTS = [
33
+ "modify",
34
+ "accept",
35
+ "reject",
36
+ "reply",
37
+ "insert_row",
38
+ "delete_row",
39
+ ] as const;
40
+
41
+ const BUILD_STAMP_RE = /\[Adeu v[^\]]*\]/g;
42
+
43
+ describe("MCP tools — advertised schema/docs match real capability", () => {
44
+ let serverProc: ChildProcess;
45
+ let allTools: any[] = [];
46
+ const tempPaths: string[] = [];
47
+
48
+ // Fixtures
49
+ let pdbFixture: string; // repeated phrase + currency, for match_mode/regex proofs
50
+ let diffOrig: string;
51
+ let diffMod: string;
52
+ let finalizeInput: string;
53
+
54
+ const getTool = (name: string) => allTools.find((t) => t.name === name);
55
+
56
+ // --- Robust line-buffered JSON-RPC plumbing over stdio ---
57
+ const pending = new Map<number, (msg: any) => void>();
58
+ let rpcId = 100;
59
+ let stdoutBuffer = "";
60
+
61
+ function rpc(method: string, params: any): Promise<any> {
62
+ const id = ++rpcId;
63
+ return new Promise((resolveRpc, rejectRpc) => {
64
+ const timeout = setTimeout(
65
+ () => rejectRpc(new Error(`RPC timeout for ${method}`)),
66
+ 15000,
67
+ );
68
+ pending.set(id, (msg) => {
69
+ clearTimeout(timeout);
70
+ resolveRpc(msg);
71
+ });
72
+ serverProc.stdin?.write(
73
+ JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n",
74
+ );
75
+ });
76
+ }
77
+
78
+ function notify(method: string, params: any): void {
79
+ serverProc.stdin?.write(
80
+ JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n",
81
+ );
82
+ }
83
+
84
+ // Build a docx from a list of paragraph strings (cloning the empty fixture and
85
+ // clearing its body — `@adeu/core` does not export its test-utils). Tracks the
86
+ // path for cleanup.
87
+ async function buildDoc(paragraphs: string[]): Promise<string> {
88
+ const initialPath = resolve(
89
+ __dirname,
90
+ "../../../../shared/fixtures/initial.docx",
91
+ );
92
+ const doc = await DocumentObject.load(readFileSync(initialPath));
93
+ const body = doc.element;
94
+ while (body.firstChild) body.removeChild(body.firstChild);
95
+
96
+ for (const text of paragraphs) {
97
+ const xmlDoc = body.ownerDocument!;
98
+ const p = xmlDoc.createElement("w:p");
99
+ const r = xmlDoc.createElement("w:r");
100
+ const t = xmlDoc.createElement("w:t");
101
+ t.textContent = text;
102
+ if (/\s/.test(text)) t.setAttribute("xml:space", "preserve");
103
+ r.appendChild(t);
104
+ p.appendChild(r);
105
+ body.appendChild(p);
106
+ }
107
+
108
+ const outPath = join(
109
+ tmpdir(),
110
+ `adeu_schemagap_${Date.now()}_${tempPaths.length}.docx`,
111
+ );
112
+ writeFileSync(outPath, await doc.save());
113
+ tempPaths.push(outPath);
114
+ return outPath;
115
+ }
116
+
117
+ function tempOut(label: string): string {
118
+ const p = join(
119
+ tmpdir(),
120
+ `adeu_schemagap_out_${label}_${Date.now()}_${tempPaths.length}.docx`,
121
+ );
122
+ tempPaths.push(p);
123
+ return p;
124
+ }
125
+
126
+ beforeAll(async () => {
127
+ const serverPath = resolve(__dirname, "../dist/index.js");
128
+ if (!existsSync(serverPath)) {
129
+ throw new Error(
130
+ "MCP server not built. Run 'npm run build' before tests.",
131
+ );
132
+ }
133
+
134
+ serverProc = spawn("node", [serverPath]);
135
+ serverProc.stdout?.on("data", (data: Buffer) => {
136
+ stdoutBuffer += data.toString();
137
+ let idx: number;
138
+ while ((idx = stdoutBuffer.indexOf("\n")) !== -1) {
139
+ const line = stdoutBuffer.slice(0, idx).trim();
140
+ stdoutBuffer = stdoutBuffer.slice(idx + 1);
141
+ if (!line.startsWith("{")) continue;
142
+ try {
143
+ const msg = JSON.parse(line);
144
+ if (msg.id !== undefined && pending.has(msg.id)) {
145
+ const cb = pending.get(msg.id)!;
146
+ pending.delete(msg.id);
147
+ cb(msg);
148
+ }
149
+ } catch {
150
+ // ignore non-JSON / partial lines
151
+ }
152
+ }
153
+ });
154
+
155
+ // Proper MCP handshake, then snapshot the advertised tool list.
156
+ await rpc("initialize", {
157
+ protocolVersion: "2024-11-05",
158
+ capabilities: {},
159
+ clientInfo: { name: "schema-gap-test", version: "0.0.0" },
160
+ });
161
+ notify("notifications/initialized", {});
162
+
163
+ const list = await rpc("tools/list", {});
164
+ allTools = list.result.tools ?? [];
165
+
166
+ pdbFixture = await buildDoc([
167
+ "The Confidential Information shall remain protected.",
168
+ "Some unrelated clause about delivery schedules.",
169
+ "The Confidential Information shall not be disclosed.",
170
+ "Setup fee is $500 due on signing.",
171
+ ]);
172
+ diffOrig = await buildDoc(["The quick brown fox.", "Second clause stays."]);
173
+ diffMod = await buildDoc([
174
+ "The slow green turtle.",
175
+ "Second clause stays.",
176
+ ]);
177
+ finalizeInput = await buildDoc(["Some content to finalize."]);
178
+ });
179
+
180
+ afterAll(() => {
181
+ if (serverProc && !serverProc.killed) serverProc.kill();
182
+ for (const p of tempPaths) {
183
+ if (existsSync(p)) {
184
+ try {
185
+ unlinkSync(p);
186
+ } catch {
187
+ // best-effort cleanup
188
+ }
189
+ }
190
+ }
191
+ });
192
+
193
+ // ======================================================================
194
+ // process_document_batch — ADEU_TOOL_ISSUES #1: `changes` items are typed
195
+ // ======================================================================
196
+ describe("process_document_batch #1: `changes` publishes a typed item schema", () => {
197
+ it("exposes a `changes.items` schema enumerating all six change variants", () => {
198
+ const pdbTool = getTool("process_document_batch");
199
+ expect(
200
+ pdbTool,
201
+ "process_document_batch must be advertised",
202
+ ).toBeDefined();
203
+
204
+ const changesProp = pdbTool.inputSchema?.properties?.changes;
205
+ expect(changesProp?.type).toBe("array");
206
+
207
+ const items = changesProp.items;
208
+ expect(
209
+ items,
210
+ "changes.items must describe the change shape",
211
+ ).toBeTruthy();
212
+
213
+ // Every DocumentChange discriminator is now machine-discoverable.
214
+ const itemsJson = JSON.stringify(items);
215
+ for (const variant of CHANGE_VARIANTS) {
216
+ expect(
217
+ itemsJson,
218
+ `variant '${variant}' should be discoverable`,
219
+ ).toContain(variant);
220
+ }
221
+ });
222
+
223
+ it("exposes the per-variant fields (target_text / new_text / target_id / text), not just prose", () => {
224
+ const itemsJson = JSON.stringify(
225
+ getTool("process_document_batch").inputSchema.properties.changes.items,
226
+ );
227
+ for (const field of ["target_text", "new_text", "target_id", "text"]) {
228
+ expect(itemsJson).toContain(field);
229
+ }
230
+ });
231
+ });
232
+
233
+ // ======================================================================
234
+ // process_document_batch — ADEU_TOOL_ISSUES #10: match_mode / regex surfaced
235
+ // ======================================================================
236
+ describe("process_document_batch #10: match_mode / regex are documented and honored", () => {
237
+ it("documents `match_mode` and `regex` in both the schema and the description", () => {
238
+ const pdbTool = getTool("process_document_batch");
239
+ const schemaJson = JSON.stringify(pdbTool.inputSchema).toLowerCase();
240
+ const description: string = (pdbTool.description ?? "").toLowerCase();
241
+
242
+ // Discoverable from the schema (the typed change item)...
243
+ expect(schemaJson).toContain("match_mode");
244
+ expect(schemaJson).toContain("regex");
245
+ // ...and called out in the prose guidance too.
246
+ expect(description).toContain("match_mode");
247
+ expect(description).toContain("regex");
248
+ });
249
+
250
+ it("honors match_mode:'all' at the live MCP boundary — edits every occurrence (2)", async () => {
251
+ const res = await rpc("tools/call", {
252
+ name: "process_document_batch",
253
+ arguments: {
254
+ original_docx_path: pdbFixture,
255
+ author_name: "Schema Gap Test",
256
+ changes: [
257
+ {
258
+ type: "modify",
259
+ target_text: "The Confidential Information",
260
+ new_text: "The Proprietary Data",
261
+ match_mode: "all",
262
+ },
263
+ ],
264
+ dry_run: true,
265
+ },
266
+ });
267
+
268
+ const text: string = res.result.content[0].text;
269
+ expect(res.result.isError).toBeFalsy();
270
+ expect(text).toMatch(/2 occurrences/);
271
+ expect(text).toContain("`all`");
272
+ });
273
+
274
+ it("honors regex:true (with a capture group) at the live MCP boundary", async () => {
275
+ const res = await rpc("tools/call", {
276
+ name: "process_document_batch",
277
+ arguments: {
278
+ original_docx_path: pdbFixture,
279
+ author_name: "Schema Gap Test",
280
+ changes: [
281
+ {
282
+ type: "modify",
283
+ // A regex, not a literal — `target_text` never appears verbatim in
284
+ // the doc, so a successful edit proves regex mode was honored.
285
+ target_text: "\\$(\\d+)",
286
+ new_text: "USD $1",
287
+ regex: true,
288
+ },
289
+ ],
290
+ dry_run: true,
291
+ },
292
+ });
293
+
294
+ const text: string = res.result.content[0].text;
295
+ expect(res.result.isError).toBeFalsy();
296
+ expect(text).toContain("USD 500"); // capture group $1 substituted
297
+ });
298
+ });
299
+
300
+ // ======================================================================
301
+ // read_docx — build stamp appears exactly once
302
+ // ======================================================================
303
+ describe("read_docx: build stamp appears exactly once", () => {
304
+ it("stamps the build tag once — UI tools are no longer double-wrapped", () => {
305
+ const readDocx = getTool("read_docx");
306
+ expect(readDocx, "read_docx must be advertised").toBeDefined();
307
+
308
+ const stamps = readDocx.description.match(BUILD_STAMP_RE) ?? [];
309
+ expect(stamps.length).toBe(1);
310
+
311
+ // Parity with a plain (non-UI) tool, which was always stamped once.
312
+ const pdbStamps =
313
+ getTool("process_document_batch").description.match(BUILD_STAMP_RE) ??
314
+ [];
315
+ expect(pdbStamps.length).toBe(1);
316
+ });
317
+ });
318
+
319
+ // ======================================================================
320
+ // diff_docx_files — described as the Word Patch format it actually emits
321
+ // ======================================================================
322
+ describe("diff_docx_files: described as the Word Patch format it emits", () => {
323
+ it("describes its output as a Word Patch, not a 'unified diff'", () => {
324
+ const desc = getTool("diff_docx_files").description.toLowerCase();
325
+ expect(desc).not.toContain("unified diff");
326
+ expect(desc).toContain("word patch");
327
+ });
328
+
329
+ it("emits the custom `@@ Word Patch @@` format at runtime (matching its description)", async () => {
330
+ const res = await rpc("tools/call", {
331
+ name: "diff_docx_files",
332
+ arguments: {
333
+ original_path: diffOrig,
334
+ modified_path: diffMod,
335
+ compare_clean: true,
336
+ },
337
+ });
338
+
339
+ const text: string = res.result.content[0].text;
340
+ expect(res.result.isError).toBeFalsy();
341
+ expect(text).toContain("@@ Word Patch @@");
342
+ // It is NOT a standard line-based unified diff (no `@@ -l,s +l,s @@` header).
343
+ expect(text).not.toMatch(/@@ -\d+(,\d+)? \+\d+(,\d+)? @@/);
344
+ });
345
+ });
346
+
347
+ // ======================================================================
348
+ // finalize_document — encrypt fallback is disclosed
349
+ // ======================================================================
350
+ describe("finalize_document: discloses the encrypt → read-only fallback", () => {
351
+ it("advertises `encrypt` honestly — dropped from the enum, or its fallback disclosed", () => {
352
+ const finalizeTool = getTool("finalize_document");
353
+ const enumVals: string[] =
354
+ finalizeTool.inputSchema.properties.protection_mode.enum;
355
+ const desc: string = (finalizeTool.description ?? "").toLowerCase();
356
+
357
+ const honest =
358
+ !enumVals.includes("encrypt") ||
359
+ (desc.includes("encrypt") &&
360
+ /read-only|falls back|fallback|unsupported/.test(desc));
361
+ expect(
362
+ honest,
363
+ "encrypt must be dropped from the Node enum or its read-only fallback disclosed",
364
+ ).toBe(true);
365
+ });
366
+
367
+ it("downgrades encrypt to a read-only lock at runtime, with a warning (matching the disclosure)", async () => {
368
+ const res = await rpc("tools/call", {
369
+ name: "finalize_document",
370
+ arguments: {
371
+ file_path: finalizeInput,
372
+ output_path: tempOut("finalize_encrypt"),
373
+ protection_mode: "encrypt",
374
+ },
375
+ });
376
+
377
+ const text: string = res.result.content[0].text;
378
+ expect(res.result.isError).toBeFalsy();
379
+ expect(text).toContain("Encryption mode");
380
+ expect(text.toLowerCase()).toContain("unsupported");
381
+ expect(text).toMatch(/read-only/i);
382
+ });
383
+ });
384
+ });