Skip to main content
Glama
README.md
# verified-docx-mcp

An MCP server for local `.docx` files with verified writes. It is the
`docx` counterpart to
[`verified-googledocs-mcp`](https://github.com/michaelrobertsutton/verified-googledocs-mcp):
the same evidence-first contract (every mutating tool re-reads the file
after writing and returns before/after evidence, never a bare "success"),
applied to a local Word document on a synced drive (OneDrive, iCloud Drive)
instead of a Google Doc.

## Status

Issue #28 of the build plan, through WP-16a (server half; WP-16b, the
JennyStack-side KP resume rendering contract, is a separate PR). Reading
and writing a `.docx` package needs no Word installation at all — this
server manipulates OOXML directly. Word is required only for
`export_pdf` (rendering a page-accurate PDF), and that additionally
needs a macOS Automation grant for the app hosting this server's
process.

**Platform note:** this server is macOS-only, and not only for
`export_pdf`. `insert_image`'s SVG path (issue #28 WP-15a) shells out to
macOS's built-in `sips` to rasterize the PNG fallback part Word requires
for an SVG — no `cairosvg`/`rsvg-convert`/Inkscape dependency is added,
but `sips` itself is not optional and is not available on Linux/Windows.
PNG-only `insert_image` calls do not need it. (`mutations.py`'s own
`scutil`-based conflict-copy detection is the other pre-existing
macOS-only dependency, for reference.)

Tools implemented so far:

- **`export_pdf(path, output_path, close_after=True, section_keys=None)`** —
  render a `.docx` to PDF via Microsoft Word automation and report its page
  count; the result also reports `closed_after` (whether the staged copy's
  window was closed), `close_error` (why not, when it wasn't), and
  `left_open_document` (null once closed). It also reports per-section page
  spans: `sections` (each heading section's `start_page`/`start_fraction`,
  `end_page`/`end_fraction`, and derived `pages`, verified against
  `find_sections`' `heading_text`) and `page_height_pt`, restrictable to
  specific sections via `section_keys` — see the tool's own docstring for
  the default-mode-degrades-vs-explicit-mode-raises verification contract.
- **`lock_status(path)`** — report Word/LibreOffice owner-file presence
  and sync-quiesce state, as data only. Never refuses. Also reports
  `external_activity` (issue #27): whether the file on disk still matches
  what this server last wrote — the after-the-fact check for a
  co-authoring editor (Office Online) that leaves no owner file and no
  Live session. See [Live mode](#live-mode).
- **`list_parts(path)`**, **`read_document(path, format, part)`**,
  **`find_sections(path, part)`**, **`list_page_sections(path, part)`**,
  **`list_styles(path)`** — read a `.docx` part as markdown/text/runs,
  and enumerate its heading-delimited sections, page-layout sections, and
  styles. Never refuse on a locked file (a validated snapshot is read
  instead).
- **`replace_body_markdown(path, markdown, ...)`**,
  **`replace_range_markdown(path, section_key, markdown, ...)`**,
  **`append_markdown(path, markdown, ...)`** — markdown -> OOXML writes,
  guarded by a lock/sync/revision check that runs before any temp file is
  written, an atomic write with OPC validation and a rollback on a
  failed post-write verification, and a full before/after/revision
  evidence envelope on every call.
- **`replace_text(path, find, replace, expected_matches, ...)`**,
  **`format_text(path, find, style, expected_matches, ...)`** —
  targeted text edits located via a normalization ladder (exact ->
  curly/straight quotes -> NBSP/whitespace collapse -> soft-hyphen
  strip), with run splitting that clones a boundary run's original
  `w:rPr` verbatim onto every surviving piece. `format_text`'s `style`
  accepts `bold`/`italic`/`underline`/`strike` (booleans) and `color` (a
  6-hex string) — an explicit color clears any theme-color attributes on
  the run, so Word doesn't keep rendering a stale theme color over it.
  Both take `within_row_containing` (scope `find` to one table row,
  identified by a second unique anchor in that row — for a table with
  several cells reading identical text) and a `write_mode: "auto" |
  "file" | "live"` parameter (default `"auto"`): `"auto"` edits
  through a connected Word task pane instead of the file whenever a Live
  pane is connected for that document, otherwise the file path — see
  [Live mode](#live-mode) below. `apply_style(path, find, style_id,
  expected_matches, ...)` has no `write_mode` parameter and always takes
  the file path — including refusing (`LIVE_SESSION_ACTIVE`) while a Live
  pane is connected for that document, same as every other write_mode-less
  mutating tool (issue #154 — see "Live mode" below).
- **`live_save(path)`** — ask the connected pane to save the live
  document, then report both the pane's own live revision token and a
  bridge back to the file-mode revision contract (`file_revision`) for a
  caller that wants to keep working against the file afterward.
- **`list_open_items(path, source="auto")`**, **`accept_tracked_changes(path,
  revision_ids?, ...)`**, **`reject_tracked_changes(path, revision_ids?,
  ...)`** — list open comments/pending tracked changes (Google's response
  shape), and accept/reject `w:ins`/`w:del` revisions by id or all at
  once. `source="live"` reads through a connected Word task pane instead
  (issue #106 WP-4 — see "Live mode" below).
- **`track_changes: bool`** on every mutating tool above — when true,
  the edit is wrapped in `w:ins`/`w:del` (or a `w:rPrChange` for a style
  change) with an author/date/id, rather than applied directly. A
  revision authored by the server's own configured identity never blocks
  a later tracked write; one authored by anyone else does, unless
  `force=True`.
- **`add_anchored_comment(path, quote, text, expected_matches,
  write_mode="auto", ...)`**, **`get_comment_thread(path, comment_id)`**,
  **`reply_to_comment(path, comment_id, text, write_mode="auto")`**,
  **`resolve_comment(path, comment_id, write_mode="auto")`** — create an
  anchored comment, read a comment and its replies (by durable id), reply
  to one, and resolve one. Builds all five interlocking comment parts a
  real Word comment needs; verified part-by-part against a Word-authored
  golden fixture (`tests/fixtures/comments/golden-comment.docx`).
  A multi-paragraph comment's identity is keyed on its LAST paragraph,
  matching Word's own commentsIds.xml/commentsExtended.xml convention, and
  every `comment_id` `list_open_items` reports (durableId or, lacking one,
  the raw `w:id`) is accepted by the three tools above.
  `write_mode="live"` sends the same operation through a connected Word
  task pane instead (issue #106 WP-4 — see "Live mode" below), accepting
  either a `live:<id>` handle or a correlated durableId/w:id.
- **Lock guard layers 0/3/4** — every mutating tool above now runs inside
  a same-machine `.jsclaim` mutex (`O_EXCL`, released even on failure)
  alongside a no-op `remote_checkout` seam for a future Microsoft Graph
  checkout, and reports `conflict_copy_detected` (plus, when non-empty,
  `conflict_copies`/`sibling_files_changed`) after every successful
  write — a post-write sweep for sync-conflict sibling files, never
  raised as an error since the write it describes already succeeded.
  Detection only, not prevention (core/document-backend-protocol.md §9):
  the naming patterns it matches are client- and locale-dependent, so
  their absence is not proof no conflict occurred.
- **`diff_body_vs_file(path, file_path)`** — export the docx body's
  markdown projection (the same one `read_document(format="markdown")`
  uses) and diff it against a local markdown file with `difflib`, the
  same mechanism GoogleDocs-MCP's `diff_tab_vs_file` uses. Read-only and
  affirmative-only: `identical: true` means no difference was found by
  this particular projection, not a guarantee none exists (see the
  tool's own docstring for the docx-specific reasons why, including a
  trailing newline on the file side alone surfacing as a spurious
  one-line difference).
- **`list_tables(path, part)`**, **`get_table(path, table_id, part)`** —
  enumerate every `w:tbl` (including one nested inside a cell, which gets
  its own `table_id`) and report one table's full row/cell detail,
  including `w:gridSpan`/`w:vMerge` per cell. Never refuse on a locked
  file.
- **`replace_table_row(path, table_id, row_index, cells, ...)`** —
  replace one row's cells wholesale, one markdown string per cell.
  Refuses (`MERGED_OR_NESTED_TABLE`) for the WHOLE table the moment any
  cell in it is merged (`w:gridSpan`/`w:vMerge`) or a `w:tbl` is nested
  inside a cell, mirroring GoogleDocs-MCP's own merged-cell refusal.
- **`replace_cell_markdown(path, table_id, row_index, cell_index,
  markdown, ...)`** — replace one cell's content, leaving its own
  `w:tcPr` byte-identical — the only write path safe on a merged cell,
  and the intended path for an Appendix-A style band-and-border table.
  Supports multi-level bulleted/numbered markdown inside the cell, one
  `numId`/`abstractNum` tree shared across nesting levels via increasing
  `w:ilvl`, the same machinery `replace_body_markdown`/`append_markdown`
  already use.
- **`insert_table(path, rows, style_id, header_rows=0, grid_dxa=None,
  cant_split=False, anchor=None, ...)`** — insert a new table, one
  markdown string OR cell-spec object (`{"markdown", "span", "v_merge",
  "fill", "color", "bold", "align", "valign"}`) per cell — a spanning
  (`w:gridSpan`) or vertically merged (`w:vMerge`) title/header row,
  shading, and per-row `w:tblHeader` are all write paths now, not just a
  read-side report. `style_id` is REQUIRED and must name an existing
  `w:type="table"` style in the document; `grid_dxa` gives explicit
  per-column dxa widths, otherwise columns split evenly across the
  text-column width `list_page_sections` reports (unchanged default).
  `anchor` places the table by `section_key` (`position` or
  `after_paragraph_text`) or `after_table_id` instead of always appending
  at the end of the body. Cell-level `fill`/`valign`/`span`/`v_merge` live
  in `w:tcPr` and survive a later `replace_cell_markdown`; `bold`/`color`/
  `align` live on the cell's own runs/paragraphs and do not.
- **`insert_image(path, image_path, width_in, ...)`** — append a new
  inline picture at the end of the body, from a LOCAL `.png` or `.svg`
  file (read natively — no `IMAGE_SOURCE_UNSUPPORTED`, unlike
  GoogleDocs-MCP's URL-only tool). `width_in` defaults to the
  text-column width `list_page_sections` reports; an `.svg` embeds
  natively (Word 2016+'s own SVG extension) WITH a PNG fallback part
  Word requires, rasterized from the SVG's own native pixel size via
  macOS `sips`. Records `design_width_in`/`design_height_in`,
  `placed_width_in`/`placed_height_in`, and `effective_scale =
  placed_width_in / design_width_in` — a real measured ratio.
- **`apply_style(path, find, style_id, expected_matches, ...)`** — apply
  a NAMED style (from `list_styles`) to text located via `find`, the
  named-style counterpart to `format_text`'s boolean toggles. A
  character style applies to the matched run(s) exactly like
  `format_text` (including `track_changes=True`, `w:rPrChange`); a
  paragraph style applies `w:pStyle` to every paragraph containing a
  matched run, including `track_changes=True` (`w:pPrChange`, built from
  a real Word-authored fixture — see `text_edit.py`'s docstrings for the
  own-author-reuse and foreign-author-refusal rules Word's own behavior
  informed). `accept_tracked_changes`/`reject_tracked_changes` still only
  handle `w:ins`/`w:del`, never `w:rPrChange`/`w:pPrChange`.
- **`read_header_footer(path)`** — read every header/footer part's
  content as markdown in one call. Never refuses on a locked file.

More tools land in a later work package of the same plan.

## Interoperability

Every read/write path above is exercised by `tests/unit` against
Word-authored fixtures and, for comments specifically, verified part by
part against a golden `.docx` produced by the lead in Word desktop
(`tests/fixtures/comments/golden-comment.docx`; its provenance was
confirmed by the lead). What those tests cannot cover is behavior that
only shows up with a live human at a keyboard and a real sync client in
the loop: how a sync client actually names a conflict file, how Word's
own Review pane renders a tracked change, how Word Online renders a
comment this server wrote.

The lead has ruled that a live round trip through Word desktop and Word
Online is no longer a landing gate for this repo. Instead, each item
below is recorded as **verify at first real use**: the exact assumption
this server's code depends on, stated precisely enough that a future
reader knows exactly what is unproven and what would actually break if
the assumption turns out to be wrong.

1. **Conflict-copy filename (issue #28 WP-10).**
   Assumption: OneDrive names a conflict copy `<stem>-<Machine>.docx`
   using the resolved local machine name -- on this machine
   `Michaels-MacBook-Pro` (`_local_machine_names()` in `mutations.py`;
   confirmed against this machine's own `scutil --get ComputerName` /
   `LocalHostName` output, both of which already normalize to that
   string). `conflict_copy_sweep`'s `_matches_conflict_copy_pattern`
   matches that branch only on an exact, case-insensitive match against
   `_local_machine_names()`.
   If wrong: a real OneDrive client emitting a different machine-name
   form (a different normalization, a user-set device label, a non-Mac
   client) means a genuine conflict lands in `sibling_files_changed`
   instead of raising the `conflict_copy_detected` evidence flag --
   still visible to a caller that reads the evidence, just not flagged
   as unambiguously as the matched branch is.
   This is a deliberate, already-documented trade-off, not a gap
   discovered here: `_matches_conflict_copy_pattern`'s own docstring
   notes that a conflict copy from another, unenumerable machine falls
   through the same way, consistent with
   core/document-backend-protocol.md §4's "the absence of a match is not
   proof no conflict occurred."

2. **Word Online round trip (issue #28 WP-09).**
   Assumption: a comment created, replied to, and resolved by this
   server's tools shows correct threading and resolved state when the
   file is reopened in Word desktop AND Word Online.
   What is actually proven: every comment part this server writes
   (`comments.xml`, `commentsExtended.xml`, `commentsIds.xml`,
   `commentsExtensible.xml`, `people.xml`) is verified part by part
   against the golden, Word-desktop-authored fixture above.
   What is not proven: this server has never had one of its own comments
   opened in a live Word Online session. If wrong, Word Online's comment
   renderer disagrees with Word desktop's about some part-level detail
   the golden-fixture comparison did not catch (the fixture was authored
   in Word desktop, never in Word Online) -- threading or resolved state
   could render incorrectly specifically in the browser client this
   repo's fixtures never exercised.

3. **Review-pane author (issue #28 WP-07b-a).**
   Assumption: a tracked `replace_text` shows in Word's Review pane
   under the configured author name -- resolved by `author.py`'s
   `resolve_author_name()` from `~/.jennystack/config.json`'s
   `author_name` key, or, absent that, the current macOS account's full
   name (`pw_gecos`, falling back to `id -F`) -- currently
   `Michael Sutton` on this machine.
   What is actually proven: the OOXML this server writes is well-formed
   and matches documented Word conventions for a `w:ins`/`w:del`'s
   `w:author` attribute, and this server's own projection reads that
   author string back unchanged.
   What is not proven: nobody has opened one of these files in Word and
   looked at the Review pane. If wrong, the name Word actually displays
   could differ from the literal `w:author` string this server wrote
   (e.g. a Word-side display quirk that resolves a name against a
   signed-in account) -- the UI rendering is unobserved.

4. **Nested tracked edit (issue #28 WP-07b-a).**
   Assumption: Word renders `<w:ins><w:del>...</w:del><w:ins>...</w:ins></w:ins>`
   sensibly when a second `track_changes=True` edit lands on a still-
   pending own insertion -- the named scope limit in `text_edit.py`'s own
   module docstring.
   What is actually proven: read-back correctness holds regardless --
   `projection.py` excludes `w:del` content unconditionally, so the
   superseded text is correctly invisible to every read tool either way,
   directly exercised by
   `test_text_edit.py::test_own_author_tracked_edit_does_not_deadlock_a_second_tracked_edit`
   (chained `replace_text(..., track_changes=True)` calls over the same
   span), not merely assumed.
   What is not proven: the visual shape of that nesting in Word's own
   Review pane. If wrong, Word could render the nested insertion/deletion
   confusingly (e.g. a crossed-out "insertion" that reads as ambiguous or
   duplicated) even though every read tool in this server still reports
   the correct final text.

5. **SVG text extractability through export_pdf (issue #28 WP-15a).**
   Assumption: text inside an `insert_image`-embedded SVG survives
   `export_pdf` (Word's own PDF export of a document containing that SVG)
   as extractable text, not a rasterized/flattened image -- i.e. that
   Word's PDF exporter renders the native SVG branch (`a:blip`'s
   `a14:svgBlip` extension this server writes) as real vector text rather
   than falling back to the PNG fallback part or flattening the SVG to a
   bitmap. The plan named this a `LEAD:` live check; the lead has since
   ruled the remaining live checks are no longer a landing gate for this
   repo (see this section's own opening paragraph), so it is recorded
   here in the same form as items 1-4 instead of being run.
   What is actually proven: the OOXML this server writes is well-formed
   (`opc_valid` passes with both the SVG part and its PNG fallback part
   present and correctly related -- `tests/unit/test_images.py`), and the
   SVG's own text content is written byte-for-byte into the `.svg` media
   part (nothing about this server's own write path could corrupt or
   strip it).
   What is not proven: nobody has run `export_pdf` against a document
   this server inserted an SVG into and inspected the resulting PDF's own
   text layer. If wrong, Word's PDF exporter treats the SVG extension
   branch as non-authoritative for export purposes and falls back to
   rasterizing the PNG fallback part instead -- the PDF would still look
   correct (the fallback is a real rasterization of the same SVG, via
   macOS `sips`), but the SVG's own text would not be independently
   selectable/extractable in the PDF.

None of the five blocks this server from being used; each is a
live-Word-rendering question this repo's own test suite, which needs no
Word installation to read or write a `.docx`, cannot answer by itself.
The next real use of this server against a live synced folder is the
first opportunity to confirm or correct any of them.

## Install

Requires Python 3.12+ and [`uv`](https://docs.astral.sh/uv/).

```bash
uv sync
```

## Run

As an MCP server (stdio transport), typically registered by a client rather
than run directly:

```bash
uv run --directory "<path-to-this-clone>" verified-docx-mcp
```

Diagnose the Word render path on this machine (run from the SAME
application that will host the MCP server — macOS grants Automation, and
file access, per hosting app, not per terminal emulator in general):

```bash
uv run --directory "<path-to-this-clone>" verified-docx-mcp doctor
```

## Test

```bash
uv run pytest tests/unit
```

`tests/unit` is offline and runs against committed fixture `.docx`/`.pdf`
files — no Word installation required. `tests/live` needs a real Word
install and a macOS Automation grant; it is skipped unless `--run-live` is
passed.

## Live mode

A Word task-pane add-in (`addin/`) plus a local bridge
(`src/verified_docx_mcp/live/`) for live co-editing:
[issue #106](https://github.com/michaelrobertsutton/JennyStack/issues/106).
The bridge serves the pane over local HTTPS (port 53135) and runs a WSS
ops channel (port 53136) the pane connects to and applies
search/replace/format/comment operations from, always reading the
document back after a mutation. The MCP server starts the bridge lazily
(idempotent) the first time a live-aware tool is called.

`live_status` (read-only) reports bridge and connected-pane state:
`{bridge_running, port, ops_port, sessions: [{document_name,
document_url, connected_since, last_heartbeat_age_s, body_sha256,
requirement_sets}], session_collisions}` — an empty `sessions` list is
normal before the lead opens the pane in Word. `session_collisions`
(empty unless it's happened) flags two *different* documents that share
a file name connecting at once — `SessionRegistry` keys purely on
basename, so it can't tell them apart on its own; check this before
trusting a live write's routing if that's possible.

`write_mode: "auto" | "file" | "live"` (default `"auto"`) is wired up on
`replace_text`/`format_text` (WP-3) and on
`add_anchored_comment`/`reply_to_comment`/`resolve_comment` (WP-4), with a
parallel `source="live"` on `list_open_items` — each goes through the connected
pane instead of the on-disk `.docx` parts whenever a Live pane is connected for
that document, otherwise the file path. `live_save(path)` asks the pane to save.
`list_open_items(source="live")` carries a `correlation` list bridging
a live comment's own id back to the durableId/w:id file mode reports (Office.js's
`Comment.id` is unrelated to either).

Issue #22 B3: none of the live comment tools (or `live_save`) require
`path` to name a file that exists locally anymore — a SharePoint/OneDrive
document with no local sync now works directly, without fabricating a
same-named local stand-in file first. When there's genuinely no local
file, `list_open_items(source="live")`'s `pending_suggestions` is `null`
(never `[]`, which would look like "nothing pending") and it carries
`file_side_available: false`; `live_save`'s `file_revision` is `null`.
`read_document`/`list_parts`/`find_sections`/etc. remain file-only — this
does not add a live read path for the structural read tools.

Issue #27: `replace_cell_markdown` also takes `write_mode` — a live edit is
compare-and-set against the cell's current text and read back
independently, limited to paragraphs with bold/italic/links (anything
structural is refused before it is sent). File-mode writes also guard
against a co-author the server cannot otherwise see (Office Online): if
the file no longer matches what this server last wrote, the write refuses
with `EXTERNAL_EDITOR_ACTIVE` unless `allow_concurrent_editor=True`, and
`revision_after` is now the token of the bytes actually written, so an
overwrite landing right after a write can't hide inside it. This is
detection after the fact, not prevention — read the limits, and the
unverified Word-for-the-web section, in
[`docs/live-mode.md`](docs/live-mode.md#office-online--word-for-the-web-co-authoring-issue-27).

Issue #154: every OTHER mutating tool — `apply_style`, `insert_table`,
`insert_image`, `replace_table_row`,
`replace_range_markdown`, `replace_body_markdown`, `append_markdown`,
`accept_tracked_changes`, `reject_tracked_changes` — has no `write_mode`
parameter and always takes the file path, but now **refuses**
(`LIVE_SESSION_ACTIVE`) while a Live pane is connected for that document,
rather than writing to disk underneath Word's own open, autosaving
buffer. The remedy is to save and close the document itself in Word, not
just the pane. Every mutating tool's evidence also now carries its own
`write_mode` key (`"file"` or `"live"`), so a caller never has to infer
routing from the shape of a `revision_*` token.

Full architecture, the `write_mode` rule and
live evidence shape, the correlation algorithm, and the lead's sideload runbook:
[`docs/live-mode.md`](docs/live-mode.md).

## Path safety

Every tool resolves its path argument through an allowlist
(`VERIFIED_DOCX_MCP_ALLOWED_FILE_ROOTS`, defaulting to the user's home
directory plus the Claude Code scratch root, `/private/tmp/claude-<uid>`,
when that directory exists) and a denylist of well-known credential
locations (`~/.ssh`, `~/.aws`, etc.) that is never overridable. Setting
`VERIFIED_DOCX_MCP_ALLOWED_FILE_ROOTS` explicitly replaces the default list
verbatim rather than widening it. See `src/verified_docx_mcp/paths.py`.

## License

MIT — see [LICENSE](LICENSE).

TDQS

A4.4/5.0

Scored across 2 tools

Disambiguation5/5

export_pdf and lock_status have completely different purposes: one renders a docx to PDF, the other inspects owner/lock and sync state. There is no overlap in inputs, outputs, or side effects, so an agent should never confuse them.

Naming Consistency3/5

Both names use snake_case and are readable, but export_pdf follows an imperative verb+object pattern while lock_status is a noun phrase without a verb. Adding a verb like get_lock_status or check_lock_status would make the naming pattern consistent.

Tool Count3/5

Two tools is on the thin side and feels closer to a utility script than a full MCP server. However, the server appears intentionally scoped to safe docx rendering and preflight lock inspection, so the count is borderline rather than excessive.

Completeness4/5

The two tools cover the core workflow of checking whether a docx is locked/syncing and then rendering it to PDF. Minor gaps exist—such as no explicit docx validity check or wait-for-quiesce helper—but agents can work around them with repeated lock_status calls and careful error handling.

Maintenance

ActivityMaintained
ResponsivenessResponsive