import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { readFileSync, existsSync } from "node:fs"; import { basename, resolve, extname, dirname, isAbsolute, join } from "node:path"; import { z } from "zod"; import { registerAppTool as origRegisterAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; import fs from "node:fs"; import { identifyEngine, _extractTextFromDoc, DocumentObject, RedlineEngine, BatchValidationError, failure_envelope, create_word_patch_diff, collect_media_difference_warnings, finalize_document, parse_page_arg, PageArgKind, extract_comments_data, response_budget_limit, has_fused_json_marker, FUSED_JSON_HINT, apply_text_revision_core, TextRevisionError, TextRevisionVerificationError, } from "@adeu/core"; import { describe_illegal_control_chars } from "@adeu/core"; import { build_paginated_response, build_page_range_response, build_full_document_response, build_budget_guard_message, build_outline_response, build_appendix_response, build_search_response, render_outline_response, build_changes_response, build_fields_response, banner_for_path, fields_discovery_hint, } from "./response-builders.js"; import { docCache } from "./doc-cache.js"; import type { ProgressFn } from "./doc-cache.js"; import { MARKDOWN_UI_URI, MCP_ID_DISCOVERY_HINT, handleServerCliArgs } from "./shared.js"; import { attachProtocolAdapter } from "./protocol-adapter.js"; // Parity with Python models.py `_infer_type_in_place` + `_coerce_match_mode_in_place`. // The MCP boundary schema is permissive; these repairs let recoverable payloads // (a missing `type` that's unambiguous from the key signature, or a non-canonical // `match_mode`) succeed instead of failing the whole-array Zod parse with an // opaque -32602. Anything still un-inferrable is caught by the handler guard // below and reported per-index; anything that doesn't apply to the document is // caught by the engine's validate_edits. Mirrors how Python repairs in a // BeforeValidator ahead of its (strict) discriminated union. const MATCH_MODE_SYNONYMS: Record = { strict: "strict", first: "first", all: "all", first_only: "first", firstonly: "first", "first-only": "first", all_occurrences: "all", alloccurrences: "all", "all-occurrences": "all", every: "all", }; export function coerceChangeItemInPlace(item: any): void { if (item === null || typeof item !== "object" || Array.isArray(item)) return; // Infer a missing `type` ONLY when exactly one variant fits unambiguously. // Deliberately do NOT infer from `target_id` alone (accept vs reject is a // semantic choice) or `target_text` alone (delete_row vs empty-new_text // modify). Those stay absent and are rejected with a clear message. if (!("type" in item) || item.type === undefined || item.type === null) { if ("cells" in item) item.type = "insert_row"; else if ("text" in item && "target_id" in item) item.type = "reply"; else if ("target_text" in item && "new_text" in item) item.type = "modify"; // Parity with python models.py `_infer_type_in_place`. else if ("field" in item && "value" in item) item.type = "set_field"; } // Normalize match_mode: canonical passes through, synonyms map, anything else // (help-string echo "strict, first, or all", empty, non-string) is dropped so // the engine's "strict" default applies. Never coerce junk to "all" — that // would silently mass-edit; defaulting to strict fails safe with an // ambiguity error instead. if ("match_mode" in item) { const raw = item.match_mode; if (typeof raw !== "string") { delete item.match_mode; } else { // Own keys only: `raw` is caller-supplied, so "constructor" and // "__proto__" must miss the table (and be dropped) instead of resolving // through Object.prototype and being assigned as a match_mode. const key = raw.trim().toLowerCase(); const mapped = Object.hasOwn(MATCH_MODE_SYNONYMS, key) ? MATCH_MODE_SYNONYMS[key] : undefined; if (mapped === undefined) delete item.match_mode; else item.match_mode = mapped; } } // MCP-boundary tolerance (parity with Python models.py:427): the published // schema makes new_text optional, so a schema-following model that only // wants to annotate sends target_text + comment. The lossless reading is // the pure-comment form (new_text == target_text) — never a bounce, and // never a tracked deletion. An explicit "" is left alone: empty means // delete, and delete-with-explanation is a distinct intent. if ( item.type === "modify" && (item.new_text === undefined || item.new_text === null) ) { const target = item.target_text; const comment = item.comment; if ( typeof target === "string" && target && typeof comment === "string" && comment.trim() ) { item.new_text = target; } } } // At most this many sibling filenames are suggested in a file-not-found // error. Dumping a crowded directory's full listing (~300 names in the QA // workspace) is thousands of tokens of noise; the closest few names are what // enable one-turn self-correction (QA round 3, finding 3.3). const NOT_FOUND_SUGGESTION_CAP = 10; /** Rank candidates by similarity to the requested filename (longest common * prefix first, then shared-substring length) and return the closest few. */ function closestFilenames(requested: string, candidates: string[]): string[] { const req = requested.toLowerCase(); const score = (name: string): number => { const cand = name.toLowerCase(); let prefix = 0; while ( prefix < req.length && prefix < cand.length && req[prefix] === cand[prefix] ) { prefix++; } const stem = req.replace(/\.docx$/, ""); return prefix * 100 + (stem && cand.includes(stem) ? 50 : 0); }; return [...candidates] .sort((a, b) => score(b) - score(a) || a.localeCompare(b)) .slice(0, NOT_FOUND_SUGGESTION_CAP); } function readFileBytesOrThrow(filePath: string): Buffer { try { return readFileSync(filePath); } catch (err: any) { if (err.code === "ENOENT") { // Lean, agent-appropriate error: suggest the CLOSEST sibling .docx // files (capped) so the model can self-correct a wrong filename (e.g. // a guessed `-processed` suffix) in one turn — never the whole // directory listing, and never CLI install instructions. let available = ""; try { const dir = dirname(filePath); const docs = fs .readdirSync(dir) .filter((f) => f.toLowerCase().endsWith(".docx")); if (docs.length) { const shown = closestFilenames(basename(filePath), docs); available = ` available files: [${shown.join(", ")}]`; if (docs.length > shown.length) { available += ` (+${docs.length - shown.length} more in ${dir})`; } } else { available = ` (no .docx files found in ${dir})`; } } catch { // Directory unreadable — omit the listing rather than fail. } // Echo the path AS GIVEN — dropping the directory ("file not found: // alice_copy.docx" for qa_sandbox/alice_copy.docx) hid which path was // actually tried, and relative paths resolve against the server's cwd, // not the caller's (QA 2026-07-23 F16). The absolute-path hint applies // only when the caller actually passed a relative path (QA round 3, // finding 3.3). const hint = isAbsolute(filePath) ? "" : " Provide an absolute path — the server cannot resolve relative paths."; throw new Error(`file not found: ${filePath};${available}${hint}`); } throw err; } } /** * Overwrite disclosure for every tool that writes a document (QA 2026-07-23 * F17): repeated default-named runs clobber _processed.docx and * output_path == input silently replaces the source. `existedBefore` must be * captured BEFORE the write. Returns "" when nothing pre-existed. */ function overwriteNote( outPath: string, inputPath: string, existedBefore: boolean, ): string { if (!existedBefore) return ""; if (resolve(outPath) === resolve(inputPath)) { return `\nNote: the source document at ${outPath} was overwritten in place.`; } return `\nNote: replaced existing file ${outPath}.`; } /** * Loads a DOCX, translating low-level container errors (fflate's bare * "invalid zip data" for a text file named .docx, truncated archives, XML * soup) into a diagnosis the caller can act on (QA 2026-07-23 F19). */ async function loadDocxOrThrow( buf: Buffer, filePath: string, opts?: Parameters[1], ): Promise { try { return await DocumentObject.load(buf, opts); } catch (err: any) { throw new Error( `'${filePath}' is not a valid .docx (Word) document: ${err?.message ?? err}`, ); } } // --- Asset Loaders for UI --- const DIST_DIR = import.meta.dirname; function getAssetContent( folder: "templates" | "assets", filename: string, fallbackMessage: string, ): string { const filePath = join(DIST_DIR, folder, filename); if (existsSync(filePath)) { return readFileSync(filePath, "utf-8"); } return fallbackMessage; } // --- Tool Description Constants --- const READ_DOCX_COMMON_DESC = "Reads a DOCX file. Returns text with inline CriticMarkup for Tracked Changes and Comments: {++inserted++}, {--deleted--}, {==highlighted==}{>>comment<<}. Set clean_view=True for the finalized 'Accepted' text without markup.\n\n"; // `page` guidance lives HERE, not only on the parameter: real MCP clients // drop optional-parameter descriptions in transit, so the tool description is // the only channel guaranteed to reach the model (QA 2026-07-23 client-compat). /** * The A1.9 banner for an MCP full-view read, or null. * * Surface-aware hint (QA F11): an MCP client cannot run a shell command, so it * is pointed at the read mode rather than the CLI flag. */ async function mcpFieldsBanner(file_path: string): Promise { return banner_for_path(file_path, fields_discovery_hint(), async (p) => loadDocxOrThrow(readFileSync(p), p), ); } const READ_DOCX_TAIL = "Modes:\n- 'full' (default): paginated body content. Use page=N to navigate.\n- 'outline': heading map only — start here for large docs to plan targeted reads. Defaults to L1-L2 headings; pass outline_max_level=3-6 to see deeper structure.\n- 'appendix': defined terms, anchors, and cross-reference targets. Consult before editing legal/technical docs to avoid breaking references.\n- 'changes' (mode='changes'): a ledger of every tracked change and comment (id, type, author, page, snippet) — start here for review work instead of reading pages. Filter with changes_author, page, and changes_offset.\n- 'fields' (mode='fields'): a ledger of every content control (ordinal, class, alias/tag, location, lock/binding state, current value) — start here to discover fillable fields. Paginate with fields_offset; `page` and `search_query` do not apply.\n\n`page`: a positive integer (1-indexed, default 1), a page RANGE like '2-6' (returns up to 8 pages in one call, then names the next range), or 'all'. Pages are synthetic length-based chunks sized for LLM consumption, NOT printed Word pages. In mode='full', page='all' returns the whole body with no page chrome; oversized documents are refused with an outline and a bounded-read recipe unless force=true. With `search_query`, `page` instead restricts matches to that page (default: search all pages)."; // BUDGET: real MCP clients truncate tool descriptions at ~2048 chars — the // tail (wherever it falls) is invisible to the model. COMMON + OPERATIONS + // the appended build tag must stay under that ceiling (QA 2026-07-23 // client-compat test 1). The {#cell:} stability claim must be qualified in // the SAME paragraph (finalize/sanitize regenerates the anchors, QA F9), and // the row-op fields (`cells` etc.) must be named in prose because clients // strip the typed item schema to {} in transit (QA F10). const PROCESS_BATCH_COMMON_DESC = "Applies a batch of edits and review actions to a DOCX.\n\nBatches apply SEQUENTIALLY: each change validates against state from prior changes. Valid changes apply when others fail (salvage default): response LEADS with `PARTIAL: applied K of N` listing unapplied changes. Pass partial=false for all-or-nothing.\n\n"; const PROCESS_BATCH_OPERATIONS_DESC = "Each item in `changes` needs a `type`:\n1. 'modify': search-and-replace. `target_text` must match uniquely (`match_mode`:'strict', default) — add context or set `match_mode`:'first'/'all'. Set `regex`:true for regex matching (groups in `new_text` as $1, $2…). `new_text` supports Markdown: '#'–'######' headings, '**bold**', '_italic_', '\\n\\n' paragraph split. Omit it (with a comment) to annotate without changing text; empty string deletes. Never write CriticMarkup manually — use `comment`.\n • EMPTY CELLS: blank cells carry `{#cell:}` anchors — set `target_text` to the anchor and value in `new_text`. Pipes are display separators.\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. 'set_field': fill a form field — `field` is its 'CC:' id, tag or alias, `value` the text; list via `read_docx` `mode`:'fields'. Checkboxes take true/false, dates YYYY-MM-DD, dropdowns a listed option. Dual-writes bound stores. A locked/protected control refuses and names the override permitting it.\n5. 'insert_row': add table row — `target_text` anchors on an existing row's text, `cells` holds cell values (left-to-right), `position` is 'above'/'below' (default below). 'delete_row': remove row matching `target_text`. Disk mode only.\n\nID VOLATILITY: 'Chg:N'/'Com:N' ids shift between states — call `read_docx` before accept/reject/reply; never reuse ids from earlier turns.\n\n`author_name` sets Track Changes attribution; defaults to 'Adeu AI (TS)' when omitted."; const DIFF_DOCX_DESC = "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."; const gitSha = process.env.GIT_SHA || "unknown"; const packageVersion = process.env.PACKAGE_VERSION || "unknown"; const buildTag = ` [Adeu v${packageVersion}+${gitSha}]`; // --- Server Setup --- const server = new McpServer({ name: "adeu-redlining-service", version: packageVersion, }); // Wrap server.registerTool to inject buildTag into descriptions const originalRegisterTool = server.registerTool.bind(server); server.registerTool = (name: string, schema: any, handler?: any) => { if (schema && typeof schema === "object") { // Idempotent: UI tools route through BOTH this wrapper and the // registerAppTool wrapper, so guard against stamping the tag twice. if (schema.description && !schema.description.includes(buildTag.trim())) { schema.description = schema.description.trim() + buildTag; } } return originalRegisterTool(name, schema, handler); }; // Wrap registerAppTool to inject buildTag into descriptions const registerAppTool: typeof origRegisterAppTool = ( mcpServer, name, schema, handler, ) => { if (schema && typeof schema === "object") { if (schema.description) { schema.description = schema.description.trim() + buildTag; } } return origRegisterAppTool(mcpServer, name, schema, handler); }; // Common CSP allowing Google Fonts used by Adeu UI templates const UI_CSP = { connectDomains: ["https://fonts.googleapis.com", "https://fonts.gstatic.com"], resourceDomains: [ "https://fonts.googleapis.com", "https://fonts.gstatic.com", ], }; // ========================================== // 1. UI RESOURCES // ========================================== registerAppResource( server, MARKDOWN_UI_URI, MARKDOWN_UI_URI, { mimeType: RESOURCE_MIME_TYPE, description: "Adeu Markdown Viewer UI" }, async () => { let html = getAssetContent( "templates", "markdown_ui.html", "UI Template Not Found", ); const markedJs = getAssetContent( "assets", "marked.min.js", "window.__MARKED_ERROR = 'marked.min.js not found';", ); const svg = getAssetContent("assets", "adeu.svg", ""); html = html .replace("[[marked_js_code | safe]]", markedJs) .replace("[[ adeu_svg_code ]]", svg); return { contents: [ { uri: MARKDOWN_UI_URI, mimeType: RESOURCE_MIME_TYPE, text: html, _meta: { ui: { csp: UI_CSP } }, }, ], }; }, ); // ========================================== // 2. UI-ENABLED TOOLS // ========================================== // read_docx must DECLARE this, not merely populate structuredContent. The MCP // Apps host only forwards `structuredContent` to the UI app when the tool // advertises an outputSchema; without one it hands the app a result carrying // `content` alone and the markdown viewer has nothing to render (observed in // Claude Desktop 2026-07-27: `params=content,isError`). Mirrors // READ_DOCX_OUTPUT_SCHEMA in python/src/adeu/mcp_components/tools/document.py. // Only `markdown` is required, and the object stays loose: clients validate // the payload against this schema and reject the whole call on a mismatch, so // an edge path that omits `title` must not fail the read. const READ_DOCX_OUTPUT_SCHEMA = z .object({ markdown: z.string().describe("Document content as Markdown, for display."), title: z .string() .optional() .describe("Display title (the file name, or 'Search: ')."), file_path: z .string() .optional() .describe("Absolute path of the document that was read."), }) .loose(); registerAppTool( server, "read_docx", { title: "Read DOCX", description: READ_DOCX_COMMON_DESC + READ_DOCX_TAIL, inputSchema: z.object({ reasoning: z .string() .optional() .describe( "Why do I need to read this docx document? State this reason before any other parameter.", ), file_path: z.string().describe("Absolute path to the DOCX file."), clean_view: z .boolean() .default(false) .describe( "If False (default), returns the 'Raw' text with inline CriticMarkup. If True, returns 'Accepted' text.", ), mode: z .enum(["full", "outline", "appendix", "changes", "fields"]) .default("full") .describe( "'full' returns body content. 'outline' returns a structural heading map. 'appendix' returns defined terms. 'changes' returns tracked changes and comments ledger. 'fields' returns the content-control ledger.", ), // ONE published JSON type (string) — real MCP clients strip // property-level anyOf/oneOf to {}, losing the type and docs entirely // (QA 2026-07-23 client-compat test 2). Numbers still arrive at // runtime and are coerced to their string form before validation; the // operative guidance lives in the tool description because clients // also drop optional-parameter descriptions. page: z .preprocess( (v) => typeof v === "number" && Number.isFinite(v) ? String(v) : v, z .string() .describe( "Positive integer (1-indexed, defaults to 1) or 'all'. See the tool description for the full behavior per mode.", ), ) .optional(), force: z .boolean() .default(false) .describe( "For mode='full' with page='all': read the whole document even when it exceeds the response budget.", ), outline_max_level: z.coerce .number() .default(2) .describe("For mode='outline' only: cap on heading depth."), outline_verbose: z .boolean() .default(false) .describe("For mode='outline' only: includes metadata."), changes_author: z .string() .optional() .describe( "For mode='changes' only: filter tracked changes ledger by author name.", ), changes_offset: z.coerce .number() .int() .default(0) .describe( "For mode='changes' only: entry offset for paginating tracked changes ledger.", ), fields_offset: z.coerce .number() .int() .default(0) .describe( "For mode='fields' only: entry offset for paginating the content-control ledger.", ), search_query: z .string() .optional() .describe( "The substring or regex pattern to search for. When provided, filters results to matching paragraphs.", ), search_regex: z .boolean() .default(false) .describe( "Set to true to interpret search_query as a regular expression.", ), search_case_sensitive: z .boolean() .default(true) .describe("Set to false to perform case-insensitive matching."), max_matches: z.coerce .number() .int() .default(20) .describe( "For search queries: maximum number of search matches to return (default 20).", ), match_offset: z.coerce .number() .int() .default(0) .describe( "For search queries: 0-based match offset to start search results from for pagination (default 0).", ), full_paragraph: z .boolean() .default(false) .describe( "For search queries: return full paragraph for search matches instead of clamping snippets to ±120 chars.", ), }), outputSchema: READ_DOCX_OUTPUT_SCHEMA, _meta: { ui: { resourceUri: MARKDOWN_UI_URI } }, }, async ( { reasoning, file_path, clean_view, mode, page, force, outline_max_level, outline_verbose, search_query, search_regex, search_case_sensitive, max_matches, match_offset, full_paragraph, changes_author, changes_offset, fields_offset, }, extra?: any, ) => { try { void reasoning; const readBytes = () => readFileBytesOrThrow(file_path); // Progress relay: only when the client supplied a progressToken, and // never allowed to fail the read. Cold ingests of huge documents use // it so the first read shows live work instead of a silent stall. const progressToken = extra?._meta?.progressToken; const onProgress: ProgressFn | undefined = progressToken !== undefined && extra?.sendNotification ? async (message, progress, total) => { try { await extra.sendNotification({ method: "notifications/progress", params: { progressToken, progress, total, message }, }); } catch { /* progress is best-effort */ } } : undefined; // The cache delegates byte-reading and document-loading back to the // boundary helpers so error shapes stay identical to the uncached // handler (lean file-not-found with sibling listing; container errors // diagnosed as invalid .docx per QA 2026-07-23 F19). const loadDoc = (buf: Buffer, opts?: any) => loadDocxOrThrow(buf, file_path, opts); const getEntry = () => docCache.get(file_path, readBytes, loadDoc, onProgress); if (mode === "outline") { if (!clean_view) { const entry = await getEntry(); const res = render_outline_response( entry.outline_nodes, entry.raw_bundle.pagination.total_pages, file_path, outline_max_level, outline_verbose, ); return res as any; } // clean_view outline: rare combination, needs clean-text offsets the // cache does not keep — served by the historical uncached path. const buf = readBytes(); const doc = await loadDocxOrThrow(buf, file_path); const extract_res = _extractTextFromDoc( doc, clean_view, true, true, ) as { text: string; paragraph_offsets: Map; }; const res = build_outline_response( doc, extract_res.text, file_path, outline_max_level, outline_verbose, extract_res.paragraph_offsets, ); return res as any; } const entry = await getEntry(); const text = clean_view ? await docCache.ensureCleanText(entry, readBytes, loadDoc) : entry.raw_text; const bundle = clean_view ? await docCache.ensureCleanBundle(entry, readBytes, loadDoc) : entry.raw_bundle; if (search_query !== undefined && search_query !== null) { // In search mode, undefined `page` means "search all document pages". const res = build_search_response( text, search_query, search_regex, search_case_sensitive, page, file_path, bundle, { max_matches, match_offset, full_paragraph }, ); return res as any; } if (mode === "changes") { if (clean_view) { return { isError: true, content: [ { type: "text", text: "Error executing tool read_docx: --clean-view cannot be used with mode='changes'.", }, ], }; } const entry2 = await getEntry(); let comments_data: Record | null = null; let existing_change_ids: string[] | null = null; try { const buf = readBytes(); const doc = await loadDocxOrThrow(buf, file_path); comments_data = extract_comments_data(doc.pkg); existing_change_ids = new RedlineEngine(doc, "Adeu AI (TS)", { id_discovery_hint: MCP_ID_DISCOVERY_HINT, }).existing_change_ids(); } catch { // Best-effort enrichment, exactly as Python (document.py:436-451): // a ledger without comment authors still beats no ledger. } const res = build_changes_response(entry2.raw_text, file_path, { comments_data, author_filter: changes_author ?? null, page: page ?? null, offset: changes_offset, bundle: entry2.raw_bundle, existing_change_ids, }); return res as any; } if (mode === "fields") { // RAW projection: the ledger previews values from the text between a // control's anchors, and the clean view drops the placeholder bubbles // that distinguish an empty control. const entryF = await getEntry(); const docF = await loadDocxOrThrow(readBytes(), file_path); const res = build_fields_response(docF, entryF.raw_text, file_path, { offset: fields_offset, bundle: entryF.raw_bundle, }); return res as any; } let pageKind: PageArgKind = "single"; let pageVal: number | [number, number] | null = 1; try { [pageKind, pageVal] = parse_page_arg(page); } catch (e: any) { return { isError: true, content: [ { type: "text", text: e.message, }, ], }; } if (mode === "appendix") { if (pageKind === "range") { return { isError: true, content: [ { type: "text", text: "Page range pagination is only supported in 'full' mode, not 'appendix' mode.", }, ], }; } if (pageKind === "all") { return { isError: true, content: [ { type: "text", text: `Invalid page parameter: '${page}'. Provide a positive integer.`, }, ], }; } const resolvedPage = pageVal as number; const res = build_appendix_response( text, resolvedPage, file_path, bundle, ); return res as any; } if (mode === "full") { if (pageKind === "all") { // A3: an UNBOUNDED whole-document read is the one path that can // return an arbitrarily large payload. Refuse it over the budget // with the page count, the L1 outline and the bounded-read recipe; // `force` is the documented opt-out. Mirrors Python's // tools/document.py:512-529, which asks its cache for the outline of // the view it is refusing — so clean_view gets the CLEAN heading map, // not the raw one (whose page numbers and CriticMarkup-bearing // heading text describe a different projection). // // Measured on the string this path RETURNS — bundle.body, what // build_full_document_response emits — not on `text`, which on the // raw view carries the structural appendix nobody asked for here. // Python's mode='full' text is projected with include_appendix=False // (doc_cache.py:159-164), so measuring `text` refused documents whose // body fits the budget while Python served them. if (!force && bundle.body.length > response_budget_limit()) { const nodes = clean_view ? await docCache.ensureCleanOutline(entry, readBytes, loadDoc) : entry.outline_nodes; return { isError: true, content: [ { type: "text", text: build_budget_guard_message( bundle.body, file_path, nodes, bundle, ), }, ], } as any; } const res = build_full_document_response(text, file_path, bundle, { fields_banner: await mcpFieldsBanner(file_path), }); return res as any; } if (pageKind === "range") { const [startP, endP] = pageVal as [number, number]; const res = build_page_range_response( text, startP, endP, file_path, bundle, ); return res as any; } } const resolvedPage = pageVal as number; const res = build_paginated_response( text, resolvedPage, file_path, bundle, { fields_banner: await mcpFieldsBanner(file_path) }, ); return res as any; } catch (e: any) { return { isError: true, content: [ { type: "text", text: `Error executing tool read_docx: ${e.message}`, }, ], }; } }, ); // ========================================== // 3. HEADLESS TOOLS (No UI) // ========================================== // Typed shape for a single `process_document_batch` change. This makes the six // DocumentChange variants — and the modify-only `match_mode`/`regex` options — // discoverable from the tool schema itself, instead of prose alone. A bare // string is still accepted (and normalized in-handler) so double-serialized // payloads from some LLM clients keep working; only `type` is required, all // other fields are optional, and unknown keys pass through untouched. export const CHANGE_ITEM_SCHEMA = z .object({ type: z .string() .optional() .describe( "Change kind: 'modify' (search-and-replace), 'accept'/'reject' (resolve a tracked change by id), 'reply' (reply to a comment by id), 'set_field' (fill a content control), 'insert_row'/'delete_row' (table edits; disk mode only). If omitted it is inferred when unambiguous from the other fields.", ), target_text: z .string() .optional() .describe( "modify / insert_row / delete_row: the existing text to locate (interpreted as a regex when regex=true).", ), new_text: z .string() .optional() .describe( "modify: replacement text. Supports Markdown (headings, **bold**, _italic_, '\\n\\n' paragraph splits); empty string deletes. Regex capture groups are available as $1, $2… Omit it (with a comment) to annotate without changing the text; an explicit empty string deletes.", ), // Primitive strings, deliberately: real MCP clients strip property-level // anyOf/oneOf to {} (QA 2026-07-23), so a union here would erase both the // type and this guidance client-side. field: z .string() .optional() .describe( "set_field only: which control to fill - the 'CC:' id, its tag, or its alias. Run read_docx with mode='fields' to list them.", ), value: z .string() .optional() .describe( "set_field only: the value to write. Checkboxes take true/false; dates take YYYY-MM-DD; dropdowns must match a listed option. Empty string clears the field.", ), target_id: z .string() .optional() .describe( "accept / reject / reply: the 'Chg:N' or 'Com:N' id taken from a fresh read_docx.", ), part: z .string() .optional() .describe( "accept / reject: the package part holding the change, e.g. 'word/header1.xml'. Revision ids are numbered per part, so the same Chg:N can name unrelated changes in different parts; a bare ambiguous id is refused with an error listing the parts, and this field picks one. Omit whenever the id is unique (the usual case).", ), text: z.string().optional().describe("reply: the reply body."), comment: z .string() .optional() .describe( "modify: attach a margin comment to the edited text. accept / reject: record the rationale as a margin comment anchored where the change was resolved (reported as Com:N).", ), match_mode: z .enum(["strict", "first", "all"]) .optional() .describe( "modify only: 'strict' (default — target must match uniquely), 'first' (first occurrence), or 'all' (every occurrence).", ), regex: z .boolean() .optional() .describe( "modify only: treat target_text as a regular expression (default false).", ), position: z .enum(["above", "below"]) .optional() .describe( "insert_row: place the new row above or below the matched row.", ), cells: z .array(z.string()) .optional() .describe("insert_row: the cell values for the new row, left to right."), }) .passthrough(); // Per-item repair at the schema boundary (AI_CONTEXT §6 "ONE changes // Parameter"): a stringified ITEM (the double-serialize client quirk) is // parsed back to its object, and recoverable payloads (missing `type`, // match_mode synonyms) are coerced BEFORE the typed item schema validates — // so repairs that used to happen only in the handler now survive the typed // enum checks too. UNPARSEABLE strings are smuggled through the object schema // under a marker key and unwrapped back to the raw string in the handler, so // the ENGINE remains the single authority for their error ("Invalid change // format… received a primitive string"). z.preprocess at the ITEM level // publishes the inner OBJECT schema — no anyOf, which real clients strip to // {} (QA 2026-07-23 client-compat test 2) — while `changes` itself stays a // plain REQUIRED array: a WHOLLY stringified payload still fails with a // retryable "expected array, received string". const RAW_STRING_ITEM_KEY = "__adeu_unparseable_item"; const CHANGE_ITEM_WITH_REPAIR = z.preprocess((item) => { let obj: any = item; if (typeof item === "string") { try { const parsed = JSON.parse(item); obj = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : { [RAW_STRING_ITEM_KEY]: item }; } catch { obj = { [RAW_STRING_ITEM_KEY]: item }; } } if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { coerceChangeItemInPlace(obj); } return obj; }, CHANGE_ITEM_SCHEMA); server.registerTool( "process_document_batch", { description: PROCESS_BATCH_COMMON_DESC + PROCESS_BATCH_OPERATIONS_DESC, inputSchema: { reasoning: z .string() .optional() .describe( "Why do I need to apply these changes to the document? State this reason before any other parameter.", ), original_docx_path: z .string() .describe("Absolute path to the source file."), // Defaulted, not required: real MCP clients drop primitive-typed // entries from required[] anyway, so schema-following models // legitimately omit author_name — the call must succeed cleanly // instead of dumping raw Zod issues (QA 2026-07-23 F3/client-compat). // The default matches the engine's own. author_name: z .string() .default("Adeu AI (TS)") .describe( "Name to appear in Track Changes (e.g., 'Reviewer AI'). Defaults to 'Adeu AI (TS)' when omitted.", ), // Deliberately a plain REQUIRED array of typed items. Wrapping the // ARRAY in z.preprocess (to also accept the whole array as one JSON // string) drops it out of the schema's `required` list, and a z.union // publishes an anyOf that real clients strip to {} — so per-item // tolerance lives inside CHANGE_ITEM_WITH_REPAIR's item-level // preprocess instead. A wholly stringified payload gets a clear // "expected array, received string" it can retry from. changes: z .array(CHANGE_ITEM_WITH_REPAIR) .describe( "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). Items apply SEQUENTIALLY: each one evaluates against the document state produced by the items before it, so later items may target text an earlier item introduced.", ), output_path: z.string().optional().describe("Optional output path."), // Salvage is the default (B5, parity with tools/document.py:1511-1514): // losing the changes that were right because one was wrong costs the // agent a whole round trip. The response leads with what did not land. partial: z .boolean() .default(true) .describe( "Whether to apply valid edits when some fail (salvage mode). Defaults to true.", ), // CC-4 write-gate overrides (spec-gates.md §1). All default FALSE: // §1 requires it because a truthy default survives client stripping, // and a gate that defaults to off is a gate that does not exist. // .default() rather than required, per the author_name note above: // real clients drop primitive entries from required[]. ignore_control_locks: z .boolean() .default(false) .describe( "Apply edits even inside content-locked or grouped content controls. Defaults to false. " + "Word refuses such edits, so overriding means the document owner has accepted the lock is wrong.", ), ignore_document_protection: z .boolean() .default(false) .describe( "Apply changes even when the document carries enforced editing protection " + "(read-only, fill-in-forms, comments-only, tracked-changes-only). Defaults to false.", ), allow_untracked_writes: z .boolean() .default(false) .describe( "Permit writes that Word records WITHOUT tracked changes. Defaults to false. Applies only " + "to fill-in-forms-protected documents, where Word does not record revisions at all; every " + "such write is flagged in the report. Separate from ignore_document_protection because it " + "concedes Adeu's own always-tracked guarantee rather than bypassing the author's restriction.", ), }, }, async ({ reasoning, original_docx_path, author_name, changes, output_path, partial, ignore_control_locks, ignore_document_protection, allow_untracked_writes, }) => { try { void reasoning; if (!author_name || !author_name.trim()) return { content: [ { type: "text", text: "Error: author_name cannot be empty." }, ], }; const author_ctrl = describe_illegal_control_chars(author_name); if (author_ctrl) return { content: [ { type: "text", text: `Error: author_name contains control character(s) (${author_ctrl}) ` + `that cannot be stored in a DOCX. Remove them and retry.`, }, ], }; if (!changes || changes.length === 0) return { content: [{ type: "text", text: "Error: No changes provided." }], }; // Defensive sanitization at the MCP boundary: some LLM clients // "double-serialize" nested arrays, delivering each element of `changes` // as a JSON string instead of an object. CHANGE_ITEM_WITH_REPAIR's // preprocess already repaired what it could; this second pass keeps the // tool layer safe regardless of the schema wiring, and unwraps the // marker objects the preprocess used to smuggle UNPARSEABLE strings // through the typed item schema — the engine's validate_edits stays the // single authority for their error message. const sanitizedChanges = changes.map((item: any) => { let obj: any = item; if ( obj !== null && typeof obj === "object" && !Array.isArray(obj) && RAW_STRING_ITEM_KEY in obj ) { return obj[RAW_STRING_ITEM_KEY]; } if (typeof item === "string") { try { const parsed = JSON.parse(item); obj = parsed !== null && typeof parsed === "object" ? parsed : item; } catch { obj = item; } } // Repair recoverable payloads (infer type, normalize match_mode) the // same way Python does before its union validation. if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { coerceChangeItemInPlace(obj); } return obj; }); // Boundary guard, scoped narrowly: after inference, reject only an OBJECT // that still carries no resolvable `type`. Strings, nulls, and non-objects // are intentionally left for the engine's validate_edits to report // ("Invalid change format… received a primitive"), keeping the engine the // single authority for those and avoiding a competing error surface. // A typeless object is the one case the engine can't cleanly reject (with // `type` now optional it would fall into the edits bucket as a no-op), so // it is caught here with an actionable, per-index message. const VALID_TYPES = new Set([ "modify", "accept", "reject", "reply", "set_field", "insert_row", "delete_row", ]); const typeErrors: string[] = []; // (0-based index in the caller's `changes`, reason) — the machine-readable // half of the same rejection (B9). The human line stays 1-based. const typeFailed: [number, string][] = []; sanitizedChanges.forEach((c: any, i: number) => { if ( c !== null && typeof c === "object" && !Array.isArray(c) && (!c.type || !VALID_TYPES.has(c.type)) ) { const fused = has_fused_json_marker(c.type) ? ` ${FUSED_JSON_HINT}` : ""; const reason = `missing or unrecognized "type". Use one of: modify (needs target_text + new_text), accept/reject (needs target_id like "Chg:12"), reply (needs target_id like "Com:5" + text), set_field (needs field + value), insert_row (needs target_text + cells), delete_row (needs target_text). Received keys: [${Object.keys(c).join(", ")}].${fused}`; typeErrors.push(`- Change ${i + 1}: ${reason}`); typeFailed.push([i, reason]); } }); if (typeErrors.length > 0) { const env = failure_envelope( "invalid_changes_file", typeFailed, "Batch rejected. Some changes are malformed.", typeErrors, ); return { isError: true, content: [ { type: "text", text: `Batch rejected. Some changes are malformed:\n\n${typeErrors.join("\n")}` + `\n\n\`\`\`json\n${JSON.stringify(env)}\n\`\`\``, }, ], }; } let outPath = output_path; if (!outPath) { const ext = extname(original_docx_path); const base = basename(original_docx_path, ext); const dir = dirname(original_docx_path); // Idempotency guard (parity with Python document.py): if the input is // already a processed artifact, write back to it instead of compounding // the suffix into contract_processed_processed.docx, which fragments the // agent's document state across files. if (base.endsWith("_processed") || base.endsWith("_redlined")) { outPath = resolve(dir, `${base}${ext}`); } else { outPath = resolve(dir, `${base}_processed${ext}`); } } // Hot-DOM reuse (docs/PERFORMANCE.md §5): a read_docx of this same // file version usually preceded this call — take its parse instead of // re-parsing from disk. Consume-on-take: the batch mutates the DOM. let doc = await docCache.takeHotDoc(original_docx_path); if (!doc) { const buf = readFileBytesOrThrow(original_docx_path); doc = await loadDocxOrThrow(buf, original_docx_path); } const engine = new RedlineEngine(doc, author_name, { id_discovery_hint: MCP_ID_DISCOVERY_HINT, ignore_control_locks, ignore_document_protection, allow_untracked_writes, }); let stats; try { stats = engine.process_batch(sanitizedChanges, undefined, partial); } catch (e: any) { if (e instanceof BatchValidationError) { // Pin the DOM back for the retry that typically follows a rejected // batch — but ONLY once the engine has verified that its rollback // restored the exact on-disk state. It re-pinned unconditionally // before, and a batch's review actions used to survive its own // rollback: every rejected attempt handed the retry a document // carrying the previous attempt's reply, so one reviewer comment // ended up with three identical replies (BUG 2026-08-12). An // unverified DOM is simply dropped; the retry re-parses from disk. if (engine.rollback_verified) { docCache.restoreHotDoc(original_docx_path, doc); } // Prose for the human reader, envelope for the machine one: the // indices name positions in the caller's own `changes` array // (B9; python/src/adeu/mcp_components/tools/document.py:710-717). const env = failure_envelope( "batch_validation_failed", e.failed, "Batch rejected. Some edits failed validation.", e.errors, ); return { isError: true, content: [ { type: "text", text: `Batch rejected. Some edits failed validation:\n\n${e.errors.join("\n\n")}` + `\n\n\`\`\`json\n${JSON.stringify(env)}\n\`\`\``, }, ], }; } throw e; } // Salvage that salvaged nothing is a REJECTION, not a partial success: // checked before the save so no output file is produced and no response // claims one (tools/document.py:645-661). The hot doc is deliberately // NOT restored here — unlike the transactional path above, salvage takes // no snapshot, so this DOM may carry a failed edit's partial mutations. const applied_count = (stats.edits_applied || 0) + (stats.actions_applied || 0); const engine_failed: Array<{ index: number; reason: string }> = stats.failed || []; if (applied_count === 0 && engine_failed.length > 0) { const env = failure_envelope( "batch_validation_failed", engine_failed.map((f) => [f.index, f.reason] as [number, string]), "Batch rejected. Some edits failed validation.", engine_failed.map((f) => f.reason), ); return { isError: true, content: [ { type: "text", text: `Batch rejected. Some edits failed validation:\n\n${engine_failed .map((f) => f.reason) .join("\n\n")}` + `\n\n\`\`\`json\n${JSON.stringify(env)}\n\`\`\``, }, ], }; } let overwrite_note = ""; const existedBefore = fs.existsSync(outPath); const outBuf = await doc.save(); try { fs.mkdirSync(dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, outBuf); } catch (e: any) { // Filesystem failures (name too long, missing directory, perms) // must surface as a clear, actionable error (QA H3 parity). return { isError: true, content: [ { type: "text", text: `Could not write output file '${outPath}': ${e.message}`, }, ], }; } overwrite_note = overwriteNote(outPath, original_docx_path, existedBefore); // The in-memory document IS the state of the file just written: // adopt it as the output's cache (text products built in the // background; DOM pinned for a chained edit). The agent's // read-after-edit then skips the full re-parse. docCache.primeFromDoc(outPath, doc); // A partial success is a SUCCESS response with the failures hoisted to // the top — never a failure envelope, whose recovery protocol ("Nothing // was written") would contradict the saved path in the same response // (tools/document.py:739-766). let partial_header = ""; if (partial && engine_failed.length > 0 && applied_count > 0) { const fails = [...engine_failed].sort((a, b) => a.index - b.index); const max_idx = fails.reduce((m, f) => Math.max(m, f.index), 0); const total_n = Math.max(max_idx + 1, sanitizedChanges.length); partial_header = `PARTIAL: applied ${applied_count} of ${total_n} changes. ${fails.length} failed validation:\n\n`; for (const f of fails) { partial_header += `- Change #${f.index + 1} Failed: ${f.reason}\n`; } partial_header += "\n"; } let res = partial_header + formatBatchResult(stats, outPath) + overwrite_note; if (sanitizedChanges.length === 0) { res = `⚠️ 0 changes provided — nothing to do. The output is an unmodified copy of the original.\n\n` + res; } return { content: [{ type: "text", text: res }] }; } catch (e: any) { return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }], }; } }, ); server.registerTool( "accept_all_changes", { description: "Accepts every tracked change in the document, producing a finalized clean document.\n\n" + "remove_comments (boolean, DEFAULT TRUE): also delete every comment. The default is " + "TRUE because this tool's purpose is a distributable clean document, and comments are " + "internal review notes that must not travel to a counterparty. Pass " + "remove_comments=false to accept the tracked changes while KEEPING the comments — use " + "that when the review conversation is still live. Either way the response reports how " + "many comments were deleted and names each one with its author, and comments whose " + "anchored text an accepted deletion consumes are removed regardless, exactly as Word does.", inputSchema: { reasoning: z .string() .optional() .describe( "Why do I need to accept all changes in this document? State this reason before any other parameter.", ), docx_path: z.string().describe("Absolute path to the DOCX file."), output_path: z.string().optional().describe("Optional output path."), remove_comments: z .boolean() .default(true) .describe( "Also delete every comment in the document. Defaults to true (finalized clean " + "document); pass false to keep comments while accepting the tracked changes.", ), }, }, async ({ reasoning, docx_path, output_path, remove_comments }) => { try { void reasoning; let outPath = output_path; if (!outPath) { const ext = extname(docx_path); const base = basename(docx_path, ext); const dir = dirname(docx_path); outPath = resolve(dir, `${base}_clean${ext}`); } const buf = readFileBytesOrThrow(docx_path); const doc = await loadDocxOrThrow(buf, docx_path); const engine = new RedlineEngine(doc, "Adeu AI (TS)", { id_discovery_hint: MCP_ID_DISCOVERY_HINT, }); // Revision-mark counts straight from the engine (AI_CONTEXT // "Accept-All Counts Are Revision MARKS"): a no-op must say so instead // of claiming "Accepted all changes" over an already-clean document // (QA 2026-07-23 F18). // // This surface DELIBERATELY defaults to true while the library API // defaults to false: accept_all_changes exists to produce a distributable // clean document, and shipping a counterparty a file that still carries // internal review notes is the more expensive failure // (QA_ISSUES_DISCOVERED #10, "Confidentiality risk"). What // BUG_comment_threading_anchoring_and_typography.md B2 correctly objected // to was that the inversion was SILENT and unavoidable — the caller now // has an explicit parameter, the published description states the // default, and every deleted comment is named with its author. const counts = engine.accept_all_revisions(remove_comments !== false); const removedCommentNotes = [...engine.removed_comment_notes]; const total = counts.accepted_insertions + counts.accepted_deletions + counts.accepted_formatting + counts.removed_comments; const existedBefore = fs.existsSync(outPath); const outBuf = await doc.save(); fs.mkdirSync(dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, outBuf); // This tool rewrites a document the cache may already hold products // for (output_path defaults next to the input, and may BE the input). // Not priming from `doc` here is deliberate: the prime path's // byte-equality gate is only covered for the batch pipeline, so the // correct-by-construction choice is to make the next read re-parse. docCache.invalidate(outPath); let text: string; if (total === 0) { text = `No tracked changes or comments to accept — the document is already clean. Saved to: ${outPath}`; } else { text = `Accepted all changes. Saved to: ${outPath}\n` + `Accepted: ${counts.accepted_insertions} insertion(s), ` + `${counts.accepted_deletions} deletion(s), ` + `${counts.accepted_formatting} formatting change(s).`; if (counts.removed_comments > 0) { // Deleting review content is disclosed WITH attribution: a comment the // caller did not write is somebody else's work product (B2). text += `\nComments removed: ${counts.removed_comments}.`; if (removedCommentNotes.length > 0) { text += `\nComments deleted: ${removedCommentNotes.join(", ")}`; if (remove_comments === false) { text += `\nNote: these comments were anchored to text an accepted deletion ` + `consumed, so Word removes them too. Nothing else was deleted.`; } } } else if (remove_comments === false) { text += `\nComments kept (remove_comments=false).`; } } text += overwriteNote(outPath, docx_path, existedBefore); return { content: [{ type: "text", text }], }; } catch (e: any) { return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }], }; } }, ); server.registerTool( "diff_docx_files", { description: DIFF_DOCX_DESC, inputSchema: { reasoning: z .string() .optional() .describe( "Why do I need to diff these two documents? State this reason before any other parameter.", ), original_path: z .string() .describe("Absolute path to the baseline DOCX file."), modified_path: z .string() .describe("Absolute path to the modified DOCX file."), compare_clean: z .boolean() .default(true) .describe( "If True, compares 'Accepted' state. If False, compares raw text.", ), }, }, async ({ reasoning, original_path, modified_path, compare_clean }) => { try { void reasoning; const origBuf = readFileBytesOrThrow(original_path); const modBuf = readFileBytesOrThrow(modified_path); // includeAppendix=false: the generated appendix ("used N times", // diagnostics) is not document content — diffing it produces phantom // changes no apply can consume (QA 2026-07-18 H1). const origDoc = await loadDocxOrThrow(origBuf, original_path); const modDoc = await loadDocxOrThrow(modBuf, modified_path); const origText = _extractTextFromDoc(origDoc, compare_clean, false) as string; const modText = _extractTextFromDoc(modDoc, compare_clean, false) as string; let diff = create_word_patch_diff( origText, modText, basename(original_path), basename(modified_path), ); // Identical documents used to yield ONLY the two header lines, leaving // the caller to infer that nothing differs (QA 2026-07-23 F14). Mirrors // the Python MCP wording. if (!diff.includes("@@ Word Patch @@")) { diff = `--- ${basename(original_path)}\n+++ ${basename(modified_path)}\n\n` + "No textual differences found between the documents."; } // A text diff cannot see image bytes: when embedded media differ, an // empty diff must never read as "the documents are identical" // (QA 2026-07-19 F-04). const media_warnings = collect_media_difference_warnings( new Uint8Array(origBuf), new Uint8Array(modBuf), ); const warning_text = media_warnings.length ? media_warnings.map((w) => `⚠️ ${w}`).join("\n") + "\n\n" : ""; return { content: [ { type: "text", text: warning_text + diff, }, ], }; } catch (e: any) { return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }], }; } }, ); server.registerTool( "finalize_document", { description: "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.", inputSchema: { reasoning: z .string() .optional() .describe( "Why do I need to finalize this document? State this reason before any other parameter.", ), file_path: z.string().describe("Absolute path to the DOCX file."), output_path: z.string().optional().describe("Optional output path."), sanitize_mode: z .enum(["full", "keep-markup"]) .optional() .describe("full removes all markup, keep-markup redacts metadata."), accept_all: z .boolean() .optional() .describe( "If true, auto-accepts all unresolved track changes before finalizing.", ), protection_mode: z .enum(["read_only", "encrypt"]) .optional() .describe( "Native OOXML document locking. Note: 'encrypt' is unsupported in this zero-dependency build and falls back to 'read_only'.", ), password: z.string().optional().describe("Ignored in this environment."), author: z .string() .optional() .describe("Replace all remaining markup authorship with this name."), export_pdf: z .boolean() .optional() .describe("Ignored in this environment."), }, }, async ({ reasoning, file_path, output_path, sanitize_mode, accept_all, protection_mode, author, export_pdf, }) => { try { void reasoning; let outPath = output_path; if (!outPath) { const ext = extname(file_path); const base = basename(file_path, ext); const dir = dirname(file_path); outPath = resolve(dir, `${base}_final${ext}`); } const buf = readFileBytesOrThrow(file_path); const doc = await loadDocxOrThrow(buf, file_path); const result = await finalize_document(doc, { filename: basename(file_path), sanitize_mode: (sanitize_mode as any) || "full", accept_all: accept_all as boolean, protection_mode: protection_mode as any, author: author as string, export_pdf: export_pdf as boolean, }); if (result.outBuffer) { const existedBefore = fs.existsSync(outPath); fs.mkdirSync(dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, result.outBuffer); // Sanitize/finalize rewrites the package; drop any cached products // for this path so a later read cannot serve the pre-finalize text. docCache.invalidate(outPath); const note = overwriteNote(outPath, file_path, existedBefore); return { content: [ { type: "text", text: `Saved to: ${outPath}${note}\n\n${result.reportText}`, }, ], }; } else { return { content: [ { type: "text", text: result.reportText, }, ], }; } } catch (e: any) { return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }], }; } }, ); server.registerTool( "apply_text_revision", { description: "Applies whole-text revised text to a DOCX document by computing a diff and generating " + "tracked changes. Includes a clean-text verification gate to ensure the applied document " + "matches the supplied text.\n\n" + "`revised_text` must be the complete CLEAN view of the document: read it with `read_docx` " + "(`clean_view=true`, `page='all'`), edit that text, and send ALL of it back. Never " + "CriticMarkup ({++, {--, {>>) — this tool diffs against the clean view, so markup tokens " + "would land in the document as literal prose. Never one page of a paginated extract — " + "everything absent from the text is applied as a tracked deletion.\n\n" + "INTERLOCK: a revision that drops >50% of the characters (>75% for documents under 2000 " + "characters) is refused unless you pass allow_major_deletions=true.\n\n" + "If the applied document's clean text does not then match `revised_text`, NOTHING is " + "written to output_path: a diagnostic copy is kept at .unverified.docx and the call " + "fails. output_path defaults to _redlined.docx; an existing _redlined/_processed " + "artifact is revised in place.", inputSchema: { reasoning: z .string() .optional() .describe( "Why do I need to apply this text revision? State this reason before any other parameter.", ), file_path: z.string().describe("Absolute path to the source DOCX file."), revised_text: z .string() .describe("The complete revised clean text of the document."), output_path: z .string() .optional() .describe("Optional output path for the modified DOCX."), author: z.string().optional().describe("Author name for Track Changes."), allow_major_deletions: z .boolean() .default(false) .describe( "Allow deleting >50% of characters (>75% for documents under 2000 characters).", ), }, }, async ({ reasoning, file_path, revised_text, output_path, author, allow_major_deletions, }) => { try { void reasoning; const buf = readFileBytesOrThrow(file_path); const doc = await loadDocxOrThrow(buf, file_path); const result = await apply_text_revision_core({ doc, input_path: file_path, revised_text, output_path, author, allow_major_deletions, }); const outPath = result.output_path; const existedBefore = fs.existsSync(outPath); fs.mkdirSync(dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, result.out_bytes); // Never primeFromDoc here: only the batch pipeline's byte-equality gate // is covered (see the comment on the batch tool's prime call), so the // correct-by-construction choice is to make the next read re-parse. docCache.invalidate(outPath); return { content: [ { type: "text", text: formatBatchResult(result.stats, outPath) + overwriteNote(outPath, file_path, existedBefore), }, ], }; } catch (e: any) { if (e instanceof TextRevisionVerificationError) { // The gate refused the document: keep the copy it refused, next to the // path the caller asked for, so a human can see what could not be // realized — and say so in the SAME message that reports the failure. let note = ""; try { fs.mkdirSync(dirname(e.unverified_path), { recursive: true }); fs.writeFileSync(e.unverified_path, e.unverified_bytes); } catch (werr: any) { note = ` (the diagnostic copy could not be written: ${werr.message})`; } return { isError: true, content: [{ type: "text", text: e.message + note }], }; } // A guard refusal is the agent's recovery instruction verbatim (parity // with Python's ToolError(str(e))); anything else keeps the "Error: " // shape the other tools use. return { isError: true, content: [ { type: "text", text: e instanceof TextRevisionError ? e.message : `Error: ${e.message}`, }, ], }; } }, ); // --- Formatter for process_document_batch --- export function formatBatchResult(stats: any, outPath: string): string { // Rendered markdown is the minimal form for MCP tool output; see payloads.ts for structured consumers. let res = `Batch complete. Saved to: ${outPath}\n`; // spec-gates §5: an exercised override is disclosed in the report header, // beside the impersonation warning, because both are "this batch did // something the default would not have". if (stats.overrides_note) { res += `\n*${stats.overrides_note}*\n`; } if (stats.author_impersonation_warning) { res += `\n*Warning:* ${stats.author_impersonation_warning}\n`; } const total_occurrences = stats.edits ? stats.edits.reduce( (acc: number, e: any) => acc + (e.status === "applied" ? e.occurrences_modified || 1 : 0), 0, ) : 0; const occ_text = total_occurrences > stats.edits_applied ? ` (${total_occurrences} occurrences)` : ""; const already = stats.actions_already_resolved || 0; const already_text = already ? `, ${already} already resolved (no effect)` : ""; res += `Actions: ${stats.actions_applied} applied, ${stats.actions_skipped} skipped${already_text}.\n`; res += `Edits: ${stats.edits_applied} applied${occ_text}, ${stats.edits_skipped} skipped.\n`; if (stats.edits && stats.edits.length > 0) { res += "\nDetailed Edit Reports:\n"; for (let i = 0; i < stats.edits.length; i++) { const report = stats.edits[i]; const status_indicator = report.status === "applied" ? "✅ [applied]" : "❌ [failed]"; const pagesStr = report.pages && report.pages.length > 0 ? ` (p${report.pages.join(", p")})` : ""; res += `### Edit ${i + 1} ${status_indicator}${pagesStr}\n`; if (report.heading_path) { res += `**Path:** \`${report.heading_path}\`\n`; } if (report.field) { // Audit-trail symmetry with Path: an edit inside a content control is // subject to that control's locks and binding, which decides whether a // human can keep it. res += `field: ${report.field}\n`; } const occ = report.occurrences_modified ?? 0; res += `**Mode:** \`${report.match_mode || "strict"}\` (${occ} occurrence${occ !== 1 ? "s" : ""} modified)\n`; if (report.comment) { res += `**Comment:** "${report.comment}"\n`; } if (report.error) { res += `*Error:* ${report.error}\n`; } if (report.warning) { res += `*Warning:* ${report.warning}\n`; } if (report.critic_markup) { res += `*Preview (CriticMarkup):*\n> ${report.critic_markup.split("\\n").join("\\n> ")}\n`; } res += "\n"; } } if (stats.skipped_details && stats.skipped_details.length > 0) { // Purely informational notes ("… the action itself succeeded") must not // be filed under "Skipped Details" — that header claims work was skipped // when it wasn't (QA round 3, finding 3.4). const allNotes = stats.skipped_details.every((d: string) => d.trimStart().startsWith("- Note:"), ); const header = allNotes ? "Notes:" : "Skipped Details:"; res += `\n\n${header}\n${stats.skipped_details.join("\n")}`; } return res.trim(); } // --- Startup --- async function main() { const cliOutput = handleServerCliArgs(process.argv.slice(2), packageVersion); if (cliOutput !== null) { // stdout is safe here: the stdio transport was never started. process.stdout.write(cliOutput + "\n"); return; } const transport = new StdioServerTransport(); // Attach AFTER connect: the SDK's Protocol.connect chains any pre-existing // onmessage handler *inside* its own (sdk/dist/esm/shared/protocol.js:230), // so a pre-connect wrapper cannot stop the SDK from also answering // server/discover (-32601) or a request we already rejected. See task_plan §2.4. await server.connect(transport); attachProtocolAdapter(server, transport, "adeu-redlining-service", packageVersion); const gitSha = process.env.GIT_SHA || "unknown"; const buildTs = process.env.BUILD_TIMESTAMP || "unknown"; console.error( `Adeu MCP Server (Node.js Engine: ${identifyEngine()}) running on stdio build=${gitSha}@${buildTs}`, ); } main().catch(console.error);