Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
be2b5c3
docs: add OpenSpec proposal for MCP server
pablospe Feb 6, 2026
6a7ed87
feat(mcp): add DocumentCache with LRU eviction and session author
pablospe Feb 6, 2026
089f59b
feat(mcp): add server with graceful shutdown
pablospe Feb 6, 2026
0d47d53
feat(mcp): add MCP tools for all document operations
pablospe Feb 6, 2026
05ecfd3
feat(mcp): add packaging with optional [mcp] dependency
pablospe Feb 6, 2026
7568a86
feat(mcp): add plugin auto-configuration via .mcp.json
pablospe Feb 6, 2026
8e9a3bb
docs: rewrite skill for MCP-first approach
pablospe Feb 6, 2026
71c2f98
docs: add MCP server section to README
pablospe Feb 6, 2026
eaa5fd8
fix: address PR review feedback
pablospe Feb 6, 2026
a3b5801
formatting
pablospe Feb 6, 2026
e18e0fe
fix: add debug logging for close failures and use logging.exception
pablospe Feb 6, 2026
eb7a01a
fix: move logger initialization after imports
pablospe Feb 6, 2026
8fbc996
docs: add exploration tools proposal for large document support
pablospe Apr 3, 2026
99dd17a
feat: wire up FastMCP server and update tools for hash-anchored parag…
pablospe Apr 3, 2026
3eac882
feat: update MCP tools for new return types and add rewrite tools
pablospe Apr 3, 2026
483e31f
feat: add document exploration tools for large document support
pablospe Apr 3, 2026
d0c8abf
docs: mark exploration tools tasks as completed
pablospe Apr 3, 2026
d564c72
test: add tests for batch_edit, rewrite_paragraph, batch_rewrite MCP …
pablospe Apr 4, 2026
90395fd
fix: address PR review feedback (round 2)
pablospe Apr 4, 2026
30259fa
fix: skip FastMCP tests when mcp package not installed
pablospe Apr 5, 2026
63c7d2a
fix: resolve CI lint and type-check failures
pablospe Apr 5, 2026
ca050f2
style: apply ruff format to test files
pablospe Apr 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude-plugin/.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"docx": {
"command": "uvx",
"args": [
"mcp-server-docx"
]
}
}
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,50 @@ with Document.open("contract.docx", author="Editor") as doc:
])
doc.save()
```

## MCP Server (Optional)

For faster performance when making multiple edits, use the MCP (Model Context Protocol) server. It keeps documents loaded between operations, making repeated edits 10-30x faster.

### Installation

```bash
pip install docx-editor[mcp]
```

### Running the Server

```bash
# Via console script
mcp-server-docx

# Or via Python module
python -m docx_editor_mcp
```

### Claude Code Configuration

The docx-editor plugin automatically configures the MCP server when installed. For manual configuration:

```bash
claude mcp add docx -- uvx mcp-server-docx
```

Or add to your `.mcp.json`:

```json
{
"docx": {
"command": "uvx",
"args": ["mcp-server-docx"]
}
}
```

### Available MCP Tools

- **Document lifecycle**: `open_document`, `save_document`, `close_document`, `reload_document`, `force_save`
- **Track changes**: `replace_text`, `delete_text`, `insert_after`, `insert_before`
- **Comments**: `add_comment`, `list_comments`, `reply_to_comment`, `resolve_comment`, `delete_comment`
- **Revisions**: `list_revisions`, `accept_revision`, `reject_revision`, `accept_all`, `reject_all`
- **Read**: `find_text`, `count_matches`, `get_visible_text`
118 changes: 115 additions & 3 deletions docx_editor/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def _compute_new_ref(self, old_ref: str) -> str:
new_hash = compute_paragraph_hash(p)
return f"P{ref.index}#{new_hash}"

def list_paragraphs(self, max_chars: int = 80) -> list[str]:
def list_paragraphs(self, max_chars: int = 80, *, start: int = 0, limit: int = 0) -> list[str]:
"""List all paragraphs with hash-anchored references.

Returns a list of strings like "P1#a7b2| Introduction to the..."
Expand All @@ -159,20 +159,26 @@ def list_paragraphs(self, max_chars: int = 80) -> list[str]:
Args:
max_chars: Maximum characters for the preview text (default 80).
Use 0 to get only hash refs without preview text.
start: Starting paragraph index (0-based, default 0).
limit: Maximum number of paragraphs to return (0 = all).

Returns:
List of hash-tagged paragraph preview strings
"""
self._ensure_open()
paragraphs = self._document_editor.dom.getElementsByTagName("w:p")
total = len(paragraphs)
end = total if limit == 0 else min(start + limit, total)
result = []
for i, p in enumerate(paragraphs, start=1):
for i in range(start, end):
p = paragraphs[i]
idx = i + 1 # 1-based
h = compute_paragraph_hash(p)
tm = build_text_map(p)
preview = tm.text[:max_chars]
if len(tm.text) > max_chars:
preview += "..."
result.append(f"P{i}#{h}| {preview}")
result.append(f"P{idx}#{h}| {preview}")
return result

def get_visible_text(self) -> str:
Expand All @@ -192,6 +198,112 @@ def get_visible_text(self) -> str:
parts.append(tm.text)
return "\n".join(parts)

def search_text(self, query: str, context_chars: int = 100) -> list[dict]:
"""Search for text in the document, returning matches with context.

Args:
query: Text to search for.
context_chars: Characters of context to include before and after each match.

Returns:
List of dicts with keys: paragraph_ref, paragraph_index, context, match_start.
"""
self._ensure_open()
paragraphs = self._document_editor.dom.getElementsByTagName("w:p")
results = []
for i, p in enumerate(paragraphs):
tm = build_text_map(p)
text = tm.text
idx = 0
while True:
pos = text.find(query, idx)
if pos == -1:
break
h = compute_paragraph_hash(p)
before = text[max(0, pos - context_chars) : pos]
after = text[pos + len(query) : pos + len(query) + context_chars]
results.append({
"paragraph_ref": f"P{i + 1}#{h}",
"paragraph_index": i + 1,
"context": f"...{before}[{query}]{after}...",
"match_start": pos,
})
idx = pos + 1
return results

def get_paragraph_text(self, paragraphs: list[str]) -> list[dict]:
"""Get the full text of specific paragraphs by reference.

Args:
paragraphs: List of paragraph references (e.g., ["P1#a7b2", "P3#cc33"]).

Returns:
List of dicts with keys: ref, text, error (if ref is invalid).
"""
self._ensure_open()
all_paragraphs = self._document_editor.dom.getElementsByTagName("w:p")
total = len(all_paragraphs)
results = []
for ref_str in paragraphs:
try:
ref = ParagraphRef.parse(ref_str)
except Exception:
results.append({"ref": ref_str, "text": None, "error": f"Invalid reference: {ref_str}"})
continue
if ref.index < 1 or ref.index > total:
results.append({"ref": ref_str, "text": None, "error": f"Paragraph index out of range: {ref.index}"})
continue
p = all_paragraphs[ref.index - 1]
actual_hash = compute_paragraph_hash(p)
if actual_hash != ref.hash:
results.append({
"ref": ref_str,
"text": None,
"error": f"Hash mismatch: expected {ref.hash}, got {actual_hash}",
})
continue
tm = build_text_map(p)
results.append({"ref": ref_str, "text": tm.text, "error": None})
return results

def get_document_info(self) -> dict:
"""Get document overview information.

Returns:
Dict with keys: paragraph_count, word_count, headings (list of {level, text}).
"""
self._ensure_open()
paragraphs = self._document_editor.dom.getElementsByTagName("w:p")
total_words = 0
headings = []
for p in paragraphs:
tm = build_text_map(p)
text = tm.text.strip()
if text:
total_words += len(text.split())
# Check for heading style
pPr = None
for child in p.childNodes:
if child.nodeType == child.ELEMENT_NODE and child.tagName == "w:pPr":
pPr = child
break
if pPr:
for child in pPr.childNodes:
if child.nodeType == child.ELEMENT_NODE and child.tagName == "w:pStyle":
style_val = child.getAttribute("w:val")
if style_val.startswith("Heading"):
try:
level = int(style_val.replace("Heading", ""))
except ValueError:
level = 0
if level > 0:
headings.append({"level": level, "text": text})
return {
"paragraph_count": len(paragraphs),
"word_count": total_words,
"headings": headings,
}

def replace(self, find: str, replace_with: str, *, paragraph: str, occurrence: int = 0) -> str:
"""Replace text with tracked changes.

Expand Down
3 changes: 3 additions & 0 deletions docx_editor_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""MCP Server for docx_editor with persistent DOM caching."""

__version__ = "0.0.1"
6 changes: 6 additions & 0 deletions docx_editor_mcp/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Entry point for `python -m docx_editor_mcp`."""

from .server import main

if __name__ == "__main__":
main()
Loading
Loading