Skip to main content
Glama
README.md
# doc-agent-mcp

[![CI](https://github.com/xyyyang97/doc-agent-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/xyyyang97/doc-agent-mcp/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/doc-agent-mcp)](https://pypi.org/project/doc-agent-mcp/)
[![Python](https://img.shields.io/pypi/pyversions/doc-agent-mcp)](https://pypi.org/project/doc-agent-mcp/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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

A3.9/5.0

Scored across 13 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count5/5

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.

Completeness4/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues