Editing Word Documents with docx-mcp

SkillDocs & knowledge

Use when editing existing Word (.docx) documents with track changes, comments, footnotes, or structural validation. Triggers: reviewing contracts, marking up reports, adding revision comments, validating document structure, removing watermarks, auditing OOXML integrity, bulk text replacement with revisions, footnote management, paragraph-level edits. Requires the docx-mcp MCP server to be running.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Editing Word Documents with docx-mcp skill

What this skill tells your AI

The instructions your AI receives, as published by securityronin/docx-mcp in skill/SKILL.md and read by ahel’s review.

Overview

The docx-mcp MCP server provides tools for reading and editing .docx files with proper OOXML markup. Edits appear as real revisions in Microsoft Word — red strikethrough for deletions, green underline for insertions, comments in the sidebar.

A .docx file is a ZIP archive of XML files. This server unpacks the archive, parses all XML parts with lxml, edits the cached DOM trees directly, and repacks modified XML back into a valid .docx archive. This gives full control over OOXML markup — essential for track changes, comments, and structural validation that higher-level libraries don't expose.

When to Use

  • Editing existing .docx files with tracked changes
  • Adding comments or footnotes to documents
  • Reviewing/auditing document structure
  • Removing watermarks
  • Bulk find-and-replace with revision marks
  • Any task where changes must be visible as Word revisions

Do NOT use for: Creating new .docx from scratch (use docx-js instead), PDFs, spreadsheets, or .doc (legacy binary format — convert to .docx first).

Workflow

1. open_document("/path/to/file.docx")
2. get_headings() or get_document_info()     → understand structure
3. search_text("clause text")                → find target paragraphs
4. get_paragraph(para_id)                    → verify exact text before editing
5. delete_text(para_id, "old text")          → tracked deletion
6. insert_text(para_id, "new text")          → tracked insertion
7. add_comment(para_id, "Reason for change") → explain the edit
8. audit_document()                          → verify integrity
9. save_document("/path/to/output.docx")     → save (or omit path to overwrite)

Always audit_document() before saving to catch structural issues (orphaned footnotes, duplicate paraIds, unpaired bookmarks, missing relationship targets).

Tool Quick Reference

Document Lifecycle

ToolPurposeKey args
open_documentOpen .docx for editingpath
close_documentClose and clean up
get_document_infoStats overview
save_documentSave to .docxoutput_path (optional)

Reading

ToolPurposeKey args
get_headingsHeading tree with paraIds
search_textFind text in body/footnotes/commentsquery, regex
get_paragraphFull text of one paragraphpara_id

Track Changes

ToolPurposeKey args
insert_textTracked insertion (green underline)para_id, text, position
delete_textTracked deletion (red strikethrough)para_id, text
accept_changesAccept tracked changesauthor (optional)
reject_changesReject tracked changesauthor (optional)
set_formattingBold/italic/underline/color with tracked markuppara_id, text, bold, italic, underline, color

Tables

ToolPurposeKey args
get_tablesList all tables with cell content
add_tableInsert table after paragraphpara_id, rows, cols
modify_cellModify cell text with tracked changestable_index, row, col, text
add_table_rowAdd row to tabletable_index, cells (optional), row_idx (optional)
delete_table_rowDelete row with tracked changestable_index, row_index

Table column sizing: Column widths are in DXA (1440 DXA = 1 inch). Page content widths:

  • A4 (11,906 DXA wide, 1" margins): 9,026 DXA content width
  • US Letter (12,240 DXA wide, 1" margins): 9,360 DXA content width

Header vs data cell widths must match exactly. Even a 1 DXA difference between a header cell and its data cell causes persistent misalignment. Define column widths as constants and reuse them in every row.

Usable cell width = column width − 240 DXA (default margins: 120 DXA each side).

Font character width reference (avg DXA/char across a–z, A–Z, 0–9, measured from font files):

Formula: DXA/char = (advance / upm) × pt × 20. Word size: = pt × 2. Fitting: chars × dxa_per_char ≤ column_width − 240 (240 = default cell margins, 120 each side).

Monospaced — all variants identical width:

Font10pt11pt12ptNotes
PragmataPro (all variants)100110120Densest; exceptional Unicode coverage
Cascadia Code NF117129141VS Code / Windows Terminal default
Hack NF (all variants)120132144
Courier New (7pt / 6pt)~84 / ~72Legacy

Calibri (Office 2007–2023 body default):

Variant10pt11pt12pt
Light Italic99109119
Light100110120
Regular / Italic101111121
Bold / Bold Italic103114124

Aptos (Office 365/2024 default — replaced Calibri mid-2023):

Variant10pt11pt12pt
Light106116127
Regular / Italic108119130
Narrow Regular / Italic100110120
Narrow Bold / Bold Italic102–103112–113122–123
SemiBold111122133
Bold / Bold Italic113124136

Cambria (Office heading default):

Variant10pt11pt12pt
Italic105116126
Regular109120131
Bold Italic112123135
Bold116128139

Arial:

Variant10pt11pt12pt
Narrow Regular / Italic95104114
Narrow Bold / Bold Italic99109119
Regular / Italic116127139
Bold / Bold Italic121133145
Black137151165

Times New Roman:

Variant10pt11pt12pt
Italic106117128
Regular / Bold Italic111122133
Bold116128140

Verdana (widest — screen-optimised):

Variant10pt11pt12pt
Regular / Italic125138150
Bold / Bold Italic141155169

Georgia:

Variant10pt11pt12pt
Regular117128140
Italic118129141
Bold134147160
Bold Italic135149162

Equity B (Matthew Butterick):

Variant10pt11pt12ptNotes
Italic106117128
Regular110121132
Bold Italic113124135
Bold116127139
Caps Regular131144157lowercase = small caps
Caps Bold137151165lowercase = small caps

CJK — full-width Han/Hangul = exactly 1 em = pt × 20 DXA:

FontScriptFull-width (10/11/12pt)Half-width ASCII
SimSun (宋体)SC200 / 220 / 240100 / 110 / 120
Microsoft YaHei (微软雅黑)SC200 / 220 / 240117 / 129 / 141
PMingLiU (新細明體)TC200 / 220 / 240100 / 110 / 120
MS JhengHei (微軟正黑體)TC200 / 220 / 240116 / 128 / 139
MS PGothicJP185 / 204 / 222100 / 110 / 120
Yu GothicJP200 / 220 / 240111 / 122 / 133
Malgun Gothic (맑은 고딕)KR200 / 220 / 240110 / 121 / 132
Batang (바탕)KR200 / 220 / 240119 / 131 / 143

Other scripts:

FontScript10pt11pt12pt
MangalDevanagari (Hindi)172190207
Cordia NewThai687481

Lists

ToolPurposeKey args
add_listApply bullet/numbered list formattingpara_ids, style

Comments

ToolPurposeKey args
get_commentsList all comments
add_commentComment anchored to paragraphpara_id, text
reply_to_commentThreaded replyparent_id, text

Footnotes & Endnotes

ToolPurposeKey args
get_footnotesList all footnotes
add_footnoteFootnote with superscript refpara_id, text, url (optional hotlink)
add_footnote_refSubsequent ref to existing footnotepara_id, footnote_id
validate_footnotesCross-ref footnote IDs
get_endnotesList all endnotes
add_endnoteEndnote with superscript refpara_id, text
validate_endnotesCross-ref endnote IDs

Headers, Footers & Styles

ToolPurposeKey args
get_headers_footersList all headers/footers with text
edit_header_footerEdit header/footer text with tracked changeslocation, old_text, new_text
get_stylesList all defined styles

Properties & Images

ToolPurposeKey args
get_propertiesGet core properties (title, creator, dates)
set_propertiesSet core propertiestitle, creator, subject, description
get_imagesList embedded images with dimensions
insert_imageInsert image after paragraphpara_id, image_path

Sections & Cross-References

ToolPurposeKey args
add_page_breakInsert page break after paragraphpara_id
add_section_breakAdd section breakpara_id, break_type
set_section_propertiesSet page size/orientation/marginswidth, height, orientation, para_id (optional)
add_cross_referenceInternal hyperlink between paragraphssource_para_id, target_para_id, text

Protection & Merge

ToolPurposeKey args
set_document_protectionSet edit protection with optional passwordedit_type, password (optional)
merge_documentsMerge content from another DOCXsource_path

Validation & Audit

ToolPurposeKey args
validate_paraidsCheck paraId uniqueness
remove_watermarkRemove DRAFT watermarks
audit_documentFull structural audit

Essential Patterns

Replace Text (delete + insert on same paragraph)

1. search_text("30 days")              → find the paragraph
2. get_paragraph(para_id)              → verify exact text
3. delete_text(para_id, "30 days")     → tracked deletion
4. insert_text(para_id, "60 days")     → tracked insertion

Word shows both marks side by side: 30 days 60 days.

Batch Edits Across Multiple Paragraphs

1. search_text("Net 30", regex=False)  → returns all matches with paraIds
2. For each match:
   a. get_paragraph(para_id)           → verify context
   b. delete_text(para_id, "Net 30")
   c. insert_text(para_id, "Net 60")
   d. add_comment(para_id, "Updated payment terms per Amendment 3")
3. audit_document()                    → verify no structural damage

Add Explanatory Footnote

1. search_text("force majeure")        → find the clause
2. add_footnote(para_id, "Force majeure includes acts of God, war, pandemic, and other events beyond reasonable control.")
3. validate_footnotes()                → verify cross-references

Full Document Review

1. open_document("/path/to/contract.docx")
2. get_document_info()                 → paragraph count, headings, footnotes
3. get_headings()                      → see structure at a glance
4. audit_document()                    → check for pre-existing issues
5. ... make edits ...
6. audit_document()                    → verify edits didn't break anything
7. save_document("/path/to/contract_reviewed.docx")

Tips

  • paraId is an 8-char hex string (e.g., "1A2B3C4D"). Get them from get_headings() or search_text().
  • position in insert_text: "start", "end", or a substring to insert after.
  • author defaults to "Claude" for all tracked changes and comments.
  • Save to new file to preserve the original: save_document("/path/to/revised.docx").
  • Always verify before editing: Use get_paragraph() to see the exact text before calling delete_text(). The text must match exactly within a single run.

OOXML Pitfalls (Critical Knowledge)

These hard-won lessons prevent silent document corruption. Word may "repair" broken documents by silently rewriting your edits.

Word for Mac: File-Refuses-to-Open Causes

These four issues all produce "Word experienced an error trying to open the file" with no further detail. Each one causes a hard open failure on Word for Mac (Windows Word is more forgiving).

1. Field code elements in one <w:r> (fldChar + instrText)

Every w:fldChar and w:instrText element must live in its own separate <w:r>. Packing begin/instrText/end into a single run is schema-invalid.

# WRONG — all three in one run (causes hard open failure)
r._r.append(fld_begin)
r._r.append(instr)
r._r.append(fld_end)

# CORRECT — one element per run
def _fld_run(para, fld_type=None, instr_text=None):
    r = para.add_run()
    if fld_type:
        fc = OxmlElement('w:fldChar')
        fc.set(qn('w:fldCharType'), fld_type)
        r._r.append(fc)
    if instr_text:
        it = OxmlElement('w:instrText')
        it.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
        it.text = instr_text
        r._r.append(it)
    return r

_fld_run(para, fld_type='begin')
_fld_run(para, instr_text=' PAGE ')
_fld_run(para, fld_type='end')

Add xml:space="preserve" to every w:instrText so Word parses the instruction correctly.

2. w:titlePg without a matching "first" header/footer

w:titlePg in sectPr tells Word to use a separate first-page header/footer. Word then requires a <w:headerReference w:type="first"> entry. Declaring the flag without the part causes a load failure on Word for Mac.

Fix: either provide the "first" header/footer part, or don't set titlePg at all. In python-docx, never set section.different_first_page_header_footer = True unless you also populate section.first_page_header.

3. PNG image relationships before header/footer relationships (Word for Mac bug)

Word for Mac has an undocumented constraint: if a PNG image relationship appears before header/footer relationships in word/_rels/document.xml.rels, the file refuses to open. JPEG images before header/footer are fine; only PNG triggers this.

Relationship ordering in the rels file follows the order parts are registered in python-docx. Fix: call add_headers_footers() (or any function that accesses section.header/section.footer) before any add_picture() calls in the generator. This ensures header/footer get lower rIds than all PNG images.

def main():
    doc = setup_document()
    add_headers_footers(doc)   # ← must come BEFORE any add_picture() calls
    build_cover(doc)           # ← adds first image here
    build_section1(doc)
    # ...
4. w:abstractNum elements after w:num elements in numbering.xml

CT_Numbering is a strict OOXML sequence: all w:abstractNum elements must precede all w:num elements. If your generator merges custom numbering into a template that already has w:num entries, a naive .append() puts the new w:abstractNum after existing w:num entries — schema violation, hard open failure.

Fix: insert new abstractNum elements before the first existing w:num, then append new num elements at the end.

first_num = root.find(f'{{{W}}}num')
for child in abstract_nums:
    if first_num is not None:
        first_num.addprevious(child)   # insert before first w:num
    else:
        root.append(child)
for child in nums:
    root.append(child)                 # append after all w:num
5. wp:extent cx/cy values in the billions (corrupted EMU dimensions)

Word for Mac refuses to open a file when any <wp:extent> element has cx or cy in the billions or trillions of EMUs. Normal document images range from 1–10 million EMUs (roughly 1–11 inches at 914,400 EMU/inch).

Two distinct causes, same symptom:

Cause A — PIL BytesIO without DPI metadata: PIL.Image.save(buf, format='JPEG', quality=92) writes no DPI metadata to the BytesIO. python-docx's image parser defaults to DPI ≈ 0, computing an astronomical "natural" width. Even when width=Inches(5.8) is specified, the derived cy (height) is calculated from the corrupted natural dimensions.

# WRONG — no DPI in BytesIO → python-docx gets DPI≈0 → cx/cy in trillions
img.save(buf, format='JPEG', quality=92)

# CORRECT — embed DPI so python-docx computes sane natural dimensions
img.save(buf, format='JPEG', quality=92, dpi=(96, 96))

Cause B — double EMU conversion: A helper that calls Inches(width) internally receives a pre-converted EMU value instead of a float. Tell-tale signature: 4,849,538,688,000 = 5,303,520 × 914,400 — the display width (Inches(5.8)) multiplied by Inches() a second time.

TEXT_WIDTH = Inches(5.8)          # = 5,303,520 EMU

# WRONG — add_image() calls Inches(width) internally, caller passes EMU
add_image(doc, path, width=TEXT_WIDTH)   # → Inches(5303520) = 4,849,538,688,000

# CORRECT — pass float inches so the internal Inches() call is the only conversion
add_image(doc, path, width=5.8)

Detection script:

import zipfile
from lxml import etree

with zipfile.ZipFile("file.docx") as z:
    tree = etree.fromstring(z.read("word/document.xml"))
WPD = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
for i, ext in enumerate(tree.iter(f'{{{WPD}}}extent')):
    cx = int(ext.get('cx', 0))
    if cx > 100_000_000:  # > ~109 inches
        print(f"Drawing {i}: cx={cx:,}  *** OVERSIZED — will fail on Word for Mac")

ParaId Rules

Every <w:p> and <w:tr> element has a w14:paraId attribute.

RuleDetail
Must be uniqueAcross ALL XML parts: document.xml, footnotes.xml, headers, footers, endnotes
Must be < 0x80000000Word reserves the high bit internally — values >= 0x80000000 cause validation failure
8 hex digitsAlways uppercase, zero-padded (e.g., 1A2B3C4D)
Duplicates from copy-pasteDuplicating content creates duplicate paraIds — fix the second occurrence

The validate_paraids() tool checks all of these. Run it after any structural edits.

Footnote Rules

RuleDetail
1:1 mappingEach footnote ID must be referenced exactly once in document.xml. Multiple references to the same ID corrupt footnotes 1 and 2
IDs must be sequentialNo gaps — Word may silently renumber on recovery
Reserved IDsid="-1" (separator) and id="0" (continuation) are reserved — real footnotes start at id="1"
Each paragraph needs paraIdEvery <w:p> inside a footnote needs its own unique paraId

The validate_footnotes() tool checks reference/definition matching. Always run after adding footnotes.

Required styles: The document template must contain FootnoteReference (character style, superscript) and FootnoteText (paragraph style). Missing styles cause silent rendering failures. Check with get_styles() before adding footnotes to an unfamiliar template.

Citing the same source multiple times: You cannot reference the same footnote ID from two places. Call add_footnote() once per citation point — N citations of the same source = N separate add_footnote() calls, each with its own ID. Never reuse a footnote ID across paragraphs.

Multi-source citations at a single point: When multiple sources support one statement, make successive add_footnote() calls at the same paragraph. They render as comma-delimited superscripts (e.g., ¹,²,³) — add_footnote() automatically inserts a superscript comma run when the last run of the target paragraph is already a footnote reference.

Word Recovery Warning: When Word recovers/repairs a file, it renumbers ALL footnotes sequentially by document position, not by original ID. After any Word recovery, re-examine footnotes before further edits.

Non-Breaking Spaces

Word uses non-breaking spaces (\xa0, U+00A0) and narrow no-break spaces (\u202f, U+202F) throughout. Direct string matching fails silently. The search_text() tool handles this internally, but if you're checking text returned by get_paragraph(), be aware that what looks like a space may be \xa0.

Text Inside Hyperlinks

Text inside w:hyperlink elements may not appear in paragraph.text from some parsers. The docx-mcp tools handle this correctly, but be aware when working with documents containing many hyperlinks — the visible text in Word may differ from what a naive text extraction shows.

Tracked Changes: Key Rules

RuleDetail
Use w:delText inside w:delNever w:t — causes validation errors
Preserve w:rPr formattingCopy the original run's formatting into tracked change runs
Minimal edits onlyMark only what changes — don't wrap entire paragraphs
Deleting entire paragraphsMust also mark the paragraph mark as deleted via <w:del/> inside <w:pPr><w:rPr>, otherwise accepting changes leaves empty paragraphs

Smart Quotes

When adding text, use smart quotes for professional typography:

EntityCharacter
\u2018' (left single)
\u2019' (right single / apostrophe)
\u201C" (left double)
\u201D" (right double)
\u2013-- (en dash)

The docx-mcp tools accept Unicode text directly — pass the actual characters, not XML entities.

Element Order in <w:pPr>

The OOXML schema requires a specific element order: <w:pStyle>, <w:numPr>, <w:spacing>, <w:ind>, <w:jc>, <w:rPr> last. Out-of-order elements cause validation warnings and may trigger Word recovery.

Element Order in <w:rPr>

Child elements inside w:rPr also have a required order. Wrong order = "unreadable content" in Word:

w:rStyle → w:rFonts → w:b/w:bCs → w:i/w:iCs → w:color → w:sz/w:szCs → w:u → w:vertAlign

Common mistake: placing w:u before w:sz, or w:color after w:sz.

Heading Numbering

NEVER embed literal section numbers in heading text (e.g., "1.1 Background"). Heading numbers must come from Word's multilevel list numbering system. If you need to insert a heading via insert_text(), insert only the heading text — the numbering comes from the document's styles.

Markdown-to-DOCX Conversion Pitfalls

When converting markdown content to DOCX edits via docx-mcp, watch for:

Soft-Wrapped Lines

A long line in markdown may display across multiple lines in an editor. If you're extracting text from markdown to feed into insert_text() or delete_text(), ensure you're working with the logical line, not the display line. A [bracketed construct] that wraps across source lines must be treated as one unit.

Fake Footnotes in Markdown

Markdown doesn't have real footnotes — [^1] syntax is a convention that some parsers support. When converting markdown with footnote-style references to DOCX:

  1. Use add_footnote() to create real OOXML footnotes — don't insert [1] as plain text
  2. Map each markdown [^N] reference to an add_footnote() call on the appropriate paragraph
  3. Remove the footnote definition text from the body (it now lives in footnotes.xml)

Superscript Numbers Concatenate

When extracting text from paragraphs that contain footnote references, the superscript reference numbers are invisible in the XML text but adjacent characters concatenate. Example: "File #8" followed by superscript footnote "73" extracts as "File #873". Account for this when using search_text() with regex patterns on footnoted text.

Template List Indents May Override Your Content

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
48
Forks
16
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
docx-mcp
Source
github.com/securityronin/docx-mcp