doc-agent-mcp
# doc-agent-mcp
[](https://github.com/xyyyang97/doc-agent-mcp/actions/workflows/ci.yml)
[](https://pypi.org/project/doc-agent-mcp/)
[](https://pypi.org/project/doc-agent-mcp/)
[](LICENSE)
**A Model Context Protocol server that gives AI agents stable, semantic
operations on documents — instead of making them shuffle raw text.**
```text
Human ─────┐
↓
Document ← Markdown (.md/.markdown) and DOCX today,
↑ Tiptap / SuperDoc / Shimo / Google Docs tomorrow
AI Agent ──┘
```
LLM agents editing documents as one big string break things: they mangle
formatting they cannot see, lose images and comments, and cannot express
"insert a paragraph after section 3". `doc-agent-mcp` exposes the document as
a **normalized, addressable structure** (headings, paragraphs, list items,
tables with stable IDs) and lets agents work in a safe loop:
```text
read → propose change → inspect diff → apply → export
```
Nothing touches your file until `apply_changes` is called. Every read accepts
a `doc_hash`, so if the file changes underneath the agent mid-task, further
edits fail loudly (`stale_document`) instead of corrupting the file.
---
## The problem this solves
| Raw-text editing (typical today) | doc-agent-mcp |
| --- | --- |
| Agent rewrites the whole file to change one word | Agent replaces an exact character range in one block |
| DOCX round-trips through text converters destroy styles/comments | Edits are applied inside the original OOXML package; untouched content passes through |
| No way to review what will change before it changes | Every edit is staged with a unified diff; apply is explicit |
| Silent conflicts when humans edit concurrently | Content-hash optimistic locking; stale edits are rejected |
| Format-specific hacks hardcoded into prompts | One tool surface, any backend |
## Architecture
```text
MCP interface (13 tools)
↓
Document operation layer ← staging, diffs, hashes, search, sessions
↓ (doc_agent_mcp/service.py)
Normalized document model ← Block(h-0, p-1, li-2, tbl-0), Comment,
↓ ProposedChange (core/model.py)
Backend adapters ← parse() + serialize() per format
↓ (adapters/*_adapter.py)
Markdown · DOCX · future editors (Tiptap, SuperDoc, Shimo, Google Docs)
```
Key property: **the MCP tools never know which backend is underneath.**
Adding a new editor backend means implementing two methods — see
[ADAPTER_GUIDE.md](ADAPTER_GUIDE.md).
## Installation
From PyPI (recommended for users):
```bash
pip install doc-agent-mcp
```
Requires Python 3.10+.
From source (development):
```bash
git clone https://github.com/xyyyang97/doc-agent-mcp.git
cd doc-agent-mcp
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
```
Verify:
```bash
doc-agent-mcp --version
# doc-agent-mcp 0.1.0
```
## MCP configuration
The server speaks standard MCP over stdio.
### Claude Desktop
`claude_desktop_config.json`:
```json
{
"mcpServers": {
"doc-agent": {
"command": "/absolute/path/to/doc-agent-mcp/.venv/bin/doc-agent-mcp",
"args": ["--roots", "/Users/you/Documents"]
}
}
}
```
### Claude Code / Codex CLI
```bash
claude mcp add doc-agent -- /absolute/path/to/doc-agent-mcp/.venv/bin/doc-agent-mcp --roots ~/Documents
```
### Generic MCP client (JSON)
```json
{
"mcpServers": {
"doc-agent": {
"command": "/absolute/path/to/doc-agent-mcp/.venv/bin/doc-agent-mcp",
"args": [],
"env": {}
}
}
}
```
`--roots DIR [DIR ...]` optionally restricts all reads/writes to those
directories (recommended). Without it the server can touch any path its
process can reach — treat server configuration like filesystem credentials.
## Available tools
### Read operations (never mutate)
| Tool | Purpose |
| --- | --- |
| `read_document(path, section_id?, include_spans?, doc_hash?)` | Structured blocks with IDs; optional single-section view; reports `unmodeled_features` |
| `get_outline(path, doc_hash?)` | Headings flat + nested tree with paths |
| `find_text(path, query, scope_element_id?, is_regex?, case_sensitive?, doc_hash?)` | Exact occurrences with `(element_id, start, end)` offsets ready for `propose_replace_text`; table hits flagged `editable: false` |
| `get_comments(path, doc_hash?)` | Native comments (author, body, anchor element, quoted range) |
### Propose operations (stage a change; nothing written yet)
| Tool | Purpose |
| --- | --- |
| `propose_replace_text(path, element_id, start, end, text)` | Replace character range inside one block; returns diff preview |
| `propose_insert_block(path, anchor_id, position, kind, text, level?)` | Insert paragraph/heading/list item before or after any element (covers insert-before/after/append) |
| `propose_delete_block(path, element_id)` | Delete one whole block |
| `propose_add_comment(path, anchor_id, body, quote?, author?)` | Native Word comment (DOCX); session-only for Markdown (see limitations) |
### Commit & review
| Tool | Purpose |
| --- | --- |
| `get_changes(path)` | All staged changes with unified diffs |
| `discard_changes(path, change_ids?)` | Drop staged changes (all or selected) |
| `apply_changes(path, change_ids?, doc_hash?)` | Write to disk atomically; returns new `doc_hash` + warnings |
| `export_document(path, target_format, output_path?, title?)` | Convert via the model: md↔docx both directions |
| `list_backends()` | Registered backends and supported conversions |
Every mutating/read call accepts the `doc_hash` you got from the previous
call. If the file changed since (including by another process), you get
`{"code": "stale_document", ...}` and your staged changes are dropped —
re-read first.
## Example workflow
This is the exact loop `examples/demo_workflow.py` runs (against real files):
```python
from doc_agent_mcp.service import DocumentService
svc = DocumentService() # same facade the MCP tools wrap
# 1. Understand the document
outline = svc.get_outline("brief.md")
summary = next(h for h in outline["headings"] if h["title"] == "Executive Summary")
section = svc.read_document("brief.md", section_id=summary["id"])
# 2. Locate exact text
hit = svc.find_text("brief.md", "30 percent")["matches"][0]
# 3. Stage a change (file is untouched)
proposal = svc.propose_replace_text(
"brief.md", hit["element_id"], hit["start"], hit["end"],
"at least 30 percent (validated with finance)",
)
# 4. Review the diff
changes = svc.get_changes("brief.md")
print(changes["changes"][0]["diff"])
# 5. Commit, then export
svc.apply_changes("brief.md", doc_hash=proposal["doc_hash"])
svc.export_document("brief.md", "docx", output_path="brief.docx")
```
Over MCP the same steps are one tool call each — see the tool table above.
Run the full demo (Markdown + DOCX + export + stale-guard, all verified):
```bash
.venv/bin/python examples/demo_workflow.py
```
Sample documents live in [`examples/documents/`](examples/documents/):
`sample.md` and `sample.docx` (the latter with two native Word comments,
regenerable via `scripts/make_sample_docx.py`).
## Error handling
All errors are structured JSON — no tracebacks across the wire:
```json
{
"code": "element_not_found",
"message": "Element 'p-99' not found. Call get_outline ...",
"details": {"element_id": "p-99"}
}
```
| Code | Meaning |
| --- | --- |
| `document_not_found` | Path does not exist |
| `unsupported_format` | No backend for this extension |
| `element_not_found` | Stale/unknown element ID |
| `match_not_found` / `ambiguous_match` | Search found nothing / reserved for disambiguation |
| `validation_error` | Bad range, bad quote anchor, table-cell replace, path outside roots... |
| `stale_document` | File changed since your snapshot; staged changes were dropped |
| `change_not_found` | Unknown or already-discarded `change_id` |
| `export_error` | Unsupported conversion pair |
## Testing
```bash
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest # unit + integration + MCP protocol tests
.venv/bin/ruff check src tests # lint
.venv/bin/ruff format --check . # formatting
.venv/bin/mypy # strict type checking
```
The suite includes DOCX round-trip tests (edits verified by re-opening the
saved file with python-docx and at raw OOXML level) and an end-to-end MCP
test that spawns the server over stdio and speaks real protocol messages.
## Limitations (by design, not by accident)
The normalized model covers what Markdown and DOCX can both represent
reliably. Everything else is **explicitly surfaced** as
`unmodeled_features` on every read — never silently destroyed:
* **DOCX**: images/drawings, headers & footers, footnotes/endnotes, content
controls, tracked changes present in source are preserved untouched but
invisible to the model. Tables are plain-text cells (cell formatting not
modeled). `replace_text` refuses paragraphs containing hyperlinks (the
rewrite would destroy them).
* **Markdown**: serialization is *model-faithful, not byte-faithful* — content
survives round-trips, original line wrapping/marker style may not.
Blockquotes are flattened to their paragraphs (flagged). Reference-style
link definitions are resolved and inlined. Comments have no native home:
`propose_add_comment` stores them session-only and says so.
* **Tables**: searchable (flagged `editable: false`) but cell-level editing is
not implemented yet — delete/re-insert instead.
* **Concurrent agents**: last-writer-wins per file, guarded by hash checks;
there is no merge engine.
## Roadmap ideas
* Table cell operations (`update_table_cell`)
* Tiptap/SuperDoc adapters over their JSON models
* Google Docs adapter via Drive API (comments map natively)
* Anchored suggestions mode for Markdown (`<!-- suggestion -->` blocks)
* Multi-file workspaces and rename-safe sessions
## License
[MIT](LICENSE)
TDQS
Scored across 13 tools
Each tool targets a distinct operation: reading (read_document), outlining (get_outline), searching (find_text), comments (get_comments/propose_add_comment), editing via staging (propose_replace_text/insert/delete), change management (get/discard/apply_changes), and export/list backends. No two tools overlap in purpose, and the propose_* set is clearly separated from apply_changes.
All 13 tools follow a consistent snake_case verb_noun pattern (e.g., read_document, propose_insert_block, discard_changes). Verbs are descriptive and uniformly applied, making the naming predictable and easy to infer.
13 tools is well-scoped for a document editing server. Each tool covers a distinct lifecycle stage—read, inspect, search, comment, edit (staging), apply, export—without redundancy or excessive granularity.
The surface covers the core document workflow: reading, outlining, text search, editing (replace/insert/delete), comment creation, change staging/commit, and export. Minor gaps include no comment deletion/editing and no document creation, but these are outside the stated purpose of working with existing documents.