diff --git a/docx_mcp/server.py b/docx_mcp/server.py index e3c7592..b88cd09 100644 --- a/docx_mcp/server.py +++ b/docx_mcp/server.py @@ -2,11 +2,17 @@ Provides tools for reading and editing .docx files with full support for track changes (w:ins/w:del), comments, footnotes, and structural validation. + +Documents are keyed by an opaque `document_handle`. Clients that run multiple +concurrent editing sessions against one server process (e.g. parallel agents) +MUST pass a unique handle per session. Legacy callers that omit the handle +transparently share a single `__default__` slot — same behavior as before. """ from __future__ import annotations import json +import uuid from mcp.server.fastmcp import FastMCP @@ -18,11 +24,18 @@ "This server edits Word (.docx) documents. Open a document first with " "open_document, then use other tools to read, edit, and save. Changes are " "made with proper Word track-changes markup so they appear as revisions " - "in Microsoft Word / LibreOffice." + "in Microsoft Word / LibreOffice.\n\n" + "PARALLEL SESSIONS: every tool accepts an optional `document_handle` " + "string. open_document/create_document/create_from_markdown return the " + "handle under which the document is stored. If you run multiple " + "concurrent editing flows, pass a unique handle (e.g. a UUID) to each " + "tool call so sessions don't collide. Omitting the handle uses a shared " + "`__default__` slot — fine for a single caller, unsafe for parallel ones." ), ) -_doc: DocxDocument | None = None +_DEFAULT_HANDLE = "__default__" +_docs: dict[str, DocxDocument] = {} def _js(obj: object) -> str: @@ -30,63 +43,100 @@ def _js(obj: object) -> str: return json.dumps(obj, indent=2, ensure_ascii=False) -def _require_doc() -> DocxDocument: - if _doc is None: - raise RuntimeError("No document is open. Call open_document first.") - return _doc +def _key(handle: str) -> str: + return handle or _DEFAULT_HANDLE + + +def _resolve(handle: str) -> tuple[str, DocxDocument]: + """Return (key, doc) for a handle, or raise if no document is open there.""" + key = _key(handle) + doc = _docs.get(key) + if doc is None: + raise RuntimeError( + f"No document is open for handle {key!r}. Call open_document first." + ) + return key, doc + + +def _store(handle: str, doc: DocxDocument) -> str: + """Place a document under a handle, closing any previous occupant. Returns the key.""" + key = _key(handle) if handle else uuid.uuid4().hex + existing = _docs.get(key) + if existing is not None: + existing.close() + _docs[key] = doc + return key # ── Document lifecycle ────────────────────────────────────────────────────── @mcp.tool() -def open_document(path: str) -> str: +def open_document(path: str, document_handle: str = "") -> str: """Open a .docx file for reading and editing. - Unpacks the DOCX archive, parses all XML parts, and caches them in memory. - Only one document can be open at a time; opening a new one closes the previous. + Unpacks the DOCX archive, parses all XML parts, and caches them in memory + under a handle. Multiple documents may be open concurrently, each under + its own handle. Args: path: Absolute path to the .docx file. + document_handle: Optional handle to store this document under. Empty + string uses the shared `__default__` slot (legacy behavior); pass + a unique value (e.g. a UUID) per concurrent session for isolation. """ - global _doc - if _doc is not None: - _doc.close() - _doc = DocxDocument(path) - info = _doc.open() - return _js(info) + doc = DocxDocument(path) + info = doc.open() + key = _store(document_handle or _DEFAULT_HANDLE, doc) + out: dict[str, object] = {"handle": key} + if isinstance(info, dict): + out.update(info) + else: + out["info"] = info + return _js(out) @mcp.tool() -def close_document() -> str: - """Close the current document and clean up temporary files.""" - global _doc - if _doc is not None: - _doc.close() - _doc = None - return "Document closed." +def close_document(document_handle: str = "") -> str: + """Close a document and clean up temporary files. + + Args: + document_handle: Handle of the document to close. Empty = `__default__` slot. + """ + key = _key(document_handle) + doc = _docs.pop(key, None) + if doc is not None: + doc.close() + return f"Document {key!r} closed." + return f"No document open for handle {key!r}." @mcp.tool() def create_document( output_path: str, template_path: str | None = None, + document_handle: str = "", ) -> str: """Create a new blank .docx document (or from a .dotx template). - The document is automatically opened for editing after creation. - Use save_document to save changes, or start editing immediately - with insert_text, add_table, etc. + The document is automatically opened for editing under the given handle. + Use save_document to save changes, or start editing immediately with + insert_text, add_table, etc. Args: output_path: Path for the new .docx file. template_path: Optional path to a .dotx template file. + document_handle: Optional handle to store this document under. """ - global _doc - if _doc is not None: - _doc.close() - _doc = DocxDocument.create(output_path, template_path=template_path) - return _js(_doc.get_info()) + doc = DocxDocument.create(output_path, template_path=template_path) + key = _store(document_handle or _DEFAULT_HANDLE, doc) + info = doc.get_info() + out: dict[str, object] = {"handle": key} + if isinstance(info, dict): + out.update(info) + else: + out["info"] = info + return _js(out) @mcp.tool() @@ -95,6 +145,7 @@ def create_from_markdown( md_path: str | None = None, markdown: str | None = None, template_path: str | None = None, + document_handle: str = "", ) -> str: """Create a new .docx document from markdown content. @@ -104,24 +155,21 @@ def create_from_markdown( dashes, ellipses) is applied automatically. Provide exactly one of md_path or markdown. The document is automatically - opened for editing after creation. + opened for editing under the given handle. Args: output_path: Path for the new .docx file. md_path: Path to a .md file. Mutually exclusive with markdown. markdown: Raw markdown text. Mutually exclusive with md_path. template_path: Optional path to a .dotx template file. + document_handle: Optional handle to store this document under. """ if md_path and markdown: return "Error: md_path and markdown are mutually exclusive — provide one, not both." if not md_path and not markdown: return "Error: Either md_path or markdown must be provided." - global _doc - if _doc is not None: - _doc.close() - - _doc = DocxDocument.create(output_path, template_path=template_path) + doc = DocxDocument.create(output_path, template_path=template_path) base_dir = None if md_path: @@ -129,28 +177,37 @@ def create_from_markdown( p = Path(md_path) if not p.exists(): + doc.close() return f"Error: Markdown file not found: {md_path}" markdown = p.read_text(encoding="utf-8") base_dir = p.parent from docx_mcp.markdown import MarkdownConverter - MarkdownConverter.convert(_doc, markdown, base_dir=base_dir) - _doc.save(backup=False) - return _js(_doc.get_info()) + MarkdownConverter.convert(doc, markdown, base_dir=base_dir) + doc.save(backup=False) + key = _store(document_handle or _DEFAULT_HANDLE, doc) + info = doc.get_info() + out: dict[str, object] = {"handle": key} + if isinstance(info, dict): + out.update(info) + else: + out["info"] = info + return _js(out) @mcp.tool() -def get_document_info() -> str: +def get_document_info(document_handle: str = "") -> str: """Get overview stats: paragraph count, headings, footnotes, comments, images.""" - return _js(_require_doc().get_info()) + _, doc = _resolve(document_handle) + return _js(doc.get_info()) # ── Reading ───────────────────────────────────────────────────────────────── @mcp.tool() -def get_headings() -> str: +def get_headings(document_handle: str = "") -> str: """Get the document heading structure with levels, text, and paraIds. Returns a list of headings in document order, each with: @@ -159,11 +216,12 @@ def get_headings() -> str: - style (e.g., "Heading1") - paraId (unique paragraph identifier for targeting edits) """ - return _js(_require_doc().get_headings()) + _, doc = _resolve(document_handle) + return _js(doc.get_headings()) @mcp.tool() -def search_text(query: str, regex: bool = False) -> str: +def search_text(query: str, regex: bool = False, document_handle: str = "") -> str: """Search for text across the document body, footnotes, and comments. Args: @@ -172,26 +230,29 @@ def search_text(query: str, regex: bool = False) -> str: Returns matching paragraphs with their paraId, source part, and context. """ - return _js(_require_doc().search_text(query, regex=regex)) + _, doc = _resolve(document_handle) + return _js(doc.search_text(query, regex=regex)) @mcp.tool() -def get_paragraph(para_id: str) -> str: +def get_paragraph(para_id: str, document_handle: str = "") -> str: """Get the full text and style of a specific paragraph by its paraId. Args: para_id: The 8-character hex paraId (e.g., "1A2B3C4D"). """ - return _js(_require_doc().get_paragraph(para_id)) + _, doc = _resolve(document_handle) + return _js(doc.get_paragraph(para_id)) # ── Tables ───────────────────────────────────────────────────────────────── @mcp.tool() -def get_tables() -> str: +def get_tables(document_handle: str = "") -> str: """Get all tables with row/column counts and cell text content.""" - return _js(_require_doc().get_tables()) + _, doc = _resolve(document_handle) + return _js(doc.get_tables()) @mcp.tool() @@ -200,16 +261,11 @@ def add_table( rows: int, cols: int, author: str = "Claude", + document_handle: str = "", ) -> str: - """Insert a new table after a paragraph with tracked insertion. - - Args: - para_id: paraId of the paragraph to insert after. - rows: Number of rows. - cols: Number of columns. - author: Author name for the revision. - """ - return _js(_require_doc().add_table(para_id, rows, cols, author=author)) + """Insert a new table after a paragraph with tracked insertion.""" + _, doc = _resolve(document_handle) + return _js(doc.add_table(para_id, rows, cols, author=author)) @mcp.tool() @@ -219,17 +275,11 @@ def modify_cell( col: int, text: str, author: str = "Claude", + document_handle: str = "", ) -> str: - """Modify a table cell with tracked changes (delete old, insert new). - - Args: - table_idx: Table index (0-based). - row: Row index (0-based). - col: Column index (0-based). - text: New cell text. - author: Author name for the revision. - """ - return _js(_require_doc().modify_cell(table_idx, row, col, text, author=author)) + """Modify a table cell with tracked changes (delete old, insert new).""" + _, doc = _resolve(document_handle) + return _js(doc.modify_cell(table_idx, row, col, text, author=author)) @mcp.tool() @@ -238,17 +288,12 @@ def add_table_row( row_idx: int = -1, cells: list[str] | None = None, author: str = "Claude", + document_handle: str = "", ) -> str: - """Add a row to a table with tracked insertion. - - Args: - table_idx: Table index (0-based). - row_idx: Insert at this row index. -1 = append at end. - cells: Cell text content. Empty = empty cells. - author: Author name for the revision. - """ + """Add a row to a table with tracked insertion. row_idx=-1 appends.""" + _, doc = _resolve(document_handle) idx = row_idx if row_idx >= 0 else None - return _js(_require_doc().add_table_row(table_idx, row_idx=idx, cells=cells, author=author)) + return _js(doc.add_table_row(table_idx, row_idx=idx, cells=cells, author=author)) @mcp.tool() @@ -256,15 +301,11 @@ def delete_table_row( table_idx: int, row_idx: int, author: str = "Claude", + document_handle: str = "", ) -> str: - """Delete a table row with tracked changes. - - Args: - table_idx: Table index (0-based). - row_idx: Row index to delete (0-based). - author: Author name for the revision. - """ - return _js(_require_doc().delete_table_row(table_idx, row_idx, author=author)) + """Delete a table row with tracked changes.""" + _, doc = _resolve(document_handle) + return _js(doc.delete_table_row(table_idx, row_idx, author=author)) # ── Lists ────────────────────────────────────────────────────────────────── @@ -274,34 +315,31 @@ def delete_table_row( def add_list( para_ids: list[str], style: str = "bullet", + document_handle: str = "", ) -> str: - """Apply list formatting to paragraphs (bullet or numbered). - - Creates numbering definitions and sets w:numPr on each target paragraph. - - Args: - para_ids: List of paraIds to format as list items. - style: "bullet" or "numbered". - """ - return _js(_require_doc().add_list(para_ids, style=style)) + """Apply list formatting to paragraphs (bullet or numbered).""" + _, doc = _resolve(document_handle) + return _js(doc.add_list(para_ids, style=style)) # ── Styles ───────────────────────────────────────────────────────────────── @mcp.tool() -def get_styles() -> str: +def get_styles(document_handle: str = "") -> str: """Get all defined styles with ID, name, type, and base style.""" - return _js(_require_doc().get_styles()) + _, doc = _resolve(document_handle) + return _js(doc.get_styles()) # ── Headers / Footers ────────────────────────────────────────────────────── @mcp.tool() -def get_headers_footers() -> str: +def get_headers_footers(document_handle: str = "") -> str: """Get all headers and footers with their text content.""" - return _js(_require_doc().get_headers_footers()) + _, doc = _resolve(document_handle) + return _js(doc.get_headers_footers()) @mcp.tool() @@ -310,25 +348,21 @@ def edit_header_footer( old_text: str, new_text: str, author: str = "Claude", + document_handle: str = "", ) -> str: - """Edit text in a header or footer with tracked changes. - - Args: - location: "header" or "footer" (matches first found). - old_text: Text to find and replace. - new_text: Replacement text. - author: Author name for the revision. - """ - return _js(_require_doc().edit_header_footer(location, old_text, new_text, author=author)) + """Edit text in a header or footer with tracked changes.""" + _, doc = _resolve(document_handle) + return _js(doc.edit_header_footer(location, old_text, new_text, author=author)) # ── Properties ───────────────────────────────────────────────────────────── @mcp.tool() -def get_properties() -> str: +def get_properties(document_handle: str = "") -> str: """Get core document properties (title, creator, subject, dates, revision).""" - return _js(_require_doc().get_properties()) + _, doc = _resolve(document_handle) + return _js(doc.get_properties()) @mcp.tool() @@ -337,17 +371,12 @@ def set_properties( creator: str = "", subject: str = "", description: str = "", + document_handle: str = "", ) -> str: - """Set core document properties. - - Args: - title: Document title. Empty = unchanged. - creator: Document author/creator. Empty = unchanged. - subject: Document subject. Empty = unchanged. - description: Document description. Empty = unchanged. - """ + """Set core document properties. Empty string = unchanged.""" + _, doc = _resolve(document_handle) return _js( - _require_doc().set_properties( + doc.set_properties( title=title or None, creator=creator or None, subject=subject or None, @@ -360,9 +389,10 @@ def set_properties( @mcp.tool() -def get_images() -> str: +def get_images(document_handle: str = "") -> str: """Get all embedded images with rId, filename, content type, and dimensions.""" - return _js(_require_doc().get_images()) + _, doc = _resolve(document_handle) + return _js(doc.get_images()) @mcp.tool() @@ -371,17 +401,12 @@ def insert_image( image_path: str, width_emu: int = 2000000, height_emu: int = 2000000, + document_handle: str = "", ) -> str: - """Insert an image into the document after a paragraph. - - Args: - para_id: paraId of the paragraph to insert after. - image_path: Absolute path to the image file. - width_emu: Image width in EMUs (914400 = 1 inch). - height_emu: Image height in EMUs. - """ + """Insert an image into the document after a paragraph.""" + _, doc = _resolve(document_handle) return _js( - _require_doc().insert_image(para_id, image_path, width_emu=width_emu, height_emu=height_emu) + doc.insert_image(para_id, image_path, width_emu=width_emu, height_emu=height_emu) ) @@ -389,98 +414,69 @@ def insert_image( @mcp.tool() -def get_endnotes() -> str: +def get_endnotes(document_handle: str = "") -> str: """Get all endnotes with their ID and text content.""" - return _js(_require_doc().get_endnotes()) + _, doc = _resolve(document_handle) + return _js(doc.get_endnotes()) @mcp.tool() -def add_endnote(para_id: str, text: str) -> str: - """Add an endnote to a paragraph. - - Creates the endnote definition in endnotes.xml and adds a superscript - reference in the target paragraph. - - Args: - para_id: paraId of the paragraph to attach the endnote to. - text: Endnote text content. - """ - return _js(_require_doc().add_endnote(para_id, text)) +def add_endnote(para_id: str, text: str, document_handle: str = "") -> str: + """Add an endnote to a paragraph.""" + _, doc = _resolve(document_handle) + return _js(doc.add_endnote(para_id, text)) @mcp.tool() -def validate_endnotes() -> str: - """Cross-reference endnote IDs between document.xml and endnotes.xml. - - Checks that every endnote reference has a matching definition, - and flags orphaned definitions with no reference. - """ - return _js(_require_doc().validate_endnotes()) +def validate_endnotes(document_handle: str = "") -> str: + """Cross-reference endnote IDs between document.xml and endnotes.xml.""" + _, doc = _resolve(document_handle) + return _js(doc.validate_endnotes()) # ── Footnotes ─────────────────────────────────────────────────────────────── @mcp.tool() -def get_footnotes() -> str: +def get_footnotes(document_handle: str = "") -> str: """List all footnotes with their ID and text content.""" - return _js(_require_doc().get_footnotes()) + _, doc = _resolve(document_handle) + return _js(doc.get_footnotes()) @mcp.tool() -def add_footnote(para_id: str, text: str) -> str: - """Add a footnote to a paragraph. - - Creates the footnote definition in footnotes.xml and adds a superscript - reference number at the end of the target paragraph. - - Args: - para_id: paraId of the paragraph to attach the footnote to. - text: Footnote text content. - """ - return _js(_require_doc().add_footnote(para_id, text)) +def add_footnote(para_id: str, text: str, document_handle: str = "") -> str: + """Add a footnote to a paragraph.""" + _, doc = _resolve(document_handle) + return _js(doc.add_footnote(para_id, text)) @mcp.tool() -def validate_footnotes() -> str: - """Cross-reference footnote IDs between document.xml and footnotes.xml. - - Checks that every footnote reference in the document body has a matching - definition, and flags orphaned definitions with no reference. - """ - return _js(_require_doc().validate_footnotes()) +def validate_footnotes(document_handle: str = "") -> str: + """Cross-reference footnote IDs between document.xml and footnotes.xml.""" + _, doc = _resolve(document_handle) + return _js(doc.validate_footnotes()) # ── Sections / Page breaks ───────────────────────────────────────────── @mcp.tool() -def add_page_break(para_id: str) -> str: - """Insert a page break after a paragraph. - - Creates a new paragraph containing a page break element. - - Args: - para_id: paraId of the paragraph to insert after. - """ - return _js(_require_doc().add_page_break(para_id)) +def add_page_break(para_id: str, document_handle: str = "") -> str: + """Insert a page break after a paragraph.""" + _, doc = _resolve(document_handle) + return _js(doc.add_page_break(para_id)) @mcp.tool() def add_section_break( para_id: str, break_type: str = "nextPage", + document_handle: str = "", ) -> str: - """Add a section break at a paragraph. - - Inserts a section properties element in the paragraph, marking it as - the last paragraph of its section. - - Args: - para_id: paraId of the paragraph to place the section break on. - break_type: "nextPage", "continuous", "evenPage", or "oddPage". - """ - return _js(_require_doc().add_section_break(para_id, break_type=break_type)) + """Add a section break at a paragraph. break_type: nextPage/continuous/evenPage/oddPage.""" + _, doc = _resolve(document_handle) + return _js(doc.add_section_break(para_id, break_type=break_type)) @mcp.tool() @@ -493,21 +489,12 @@ def set_section_properties( margin_bottom: int = 0, margin_left: int = 0, margin_right: int = 0, + document_handle: str = "", ) -> str: - """Modify section properties (page size, orientation, margins). - - Args: - para_id: paraId of paragraph with section break. Empty = body section. - width: Page width in DXA (twips). 12240 = 8.5 inches. - height: Page height in DXA. 15840 = 11 inches. - orientation: "portrait" or "landscape". Empty = unchanged. - margin_top: Top margin in DXA. 0 = unchanged. - margin_bottom: Bottom margin in DXA. 0 = unchanged. - margin_left: Left margin in DXA. 0 = unchanged. - margin_right: Right margin in DXA. 0 = unchanged. - """ + """Modify section properties (page size, orientation, margins). 0/empty = unchanged.""" + _, doc = _resolve(document_handle) return _js( - _require_doc().set_section_properties( + doc.set_section_properties( para_id=para_id or None, width=width or None, height=height or None, @@ -528,18 +515,11 @@ def add_cross_reference( source_para_id: str, target_para_id: str, text: str, + document_handle: str = "", ) -> str: - """Add a cross-reference link from one paragraph to another. - - Creates a bookmark at the target (if none exists) and inserts a hyperlink - in the source paragraph with the given display text. - - Args: - source_para_id: paraId of the paragraph where the link appears. - target_para_id: paraId of the paragraph being referenced. - text: Display text for the cross-reference link. - """ - return _js(_require_doc().add_cross_reference(source_para_id, target_para_id, text)) + """Add a cross-reference link from one paragraph to another.""" + _, doc = _resolve(document_handle) + return _js(doc.add_cross_reference(source_para_id, target_para_id, text)) # ── Protection ───────────────────────────────────────────────────────────── @@ -549,72 +529,45 @@ def add_cross_reference( def set_document_protection( edit: str, password: str = "", + document_handle: str = "", ) -> str: - """Set document protection in settings.xml. - - Args: - edit: Protection type — "trackedChanges", "comments", "readOnly", - "forms", or "none" (removes protection). - password: Optional password (hashed with SHA-512). Empty = no password. - """ - return _js(_require_doc().set_document_protection(edit, password=password or None)) + """Set document protection. edit: trackedChanges/comments/readOnly/forms/none.""" + _, doc = _resolve(document_handle) + return _js(doc.set_document_protection(edit, password=password or None)) # ── Merge ────────────────────────────────────────────────────────────────── @mcp.tool() -def merge_documents(source_path: str) -> str: - """Merge another DOCX document's content into the current document. - - Appends body paragraphs and tables from the source. ParaIds are - automatically remapped to avoid collisions. - - Args: - source_path: Absolute path to the DOCX file to merge in. - """ - return _js(_require_doc().merge_documents(source_path)) +def merge_documents(source_path: str, document_handle: str = "") -> str: + """Merge another DOCX document's content into the current document.""" + _, doc = _resolve(document_handle) + return _js(doc.merge_documents(source_path)) # ── Validation ────────────────────────────────────────────────────────────── @mcp.tool() -def validate_paraids() -> str: - """Check paraId uniqueness across all document parts. - - ParaIds must be unique across document.xml, footnotes.xml, headers, footers, - and comments. They must also be valid 8-digit hex values < 0x80000000. - """ - return _js(_require_doc().validate_paraids()) +def validate_paraids(document_handle: str = "") -> str: + """Check paraId uniqueness across all document parts.""" + _, doc = _resolve(document_handle) + return _js(doc.validate_paraids()) @mcp.tool() -def remove_watermark() -> str: - """Remove VML watermarks (e.g., DRAFT) from all document headers. - - Detects and removes elements with inside header XML - files — the standard pattern for Word watermarks. - """ - return _js(_require_doc().remove_watermark()) +def remove_watermark(document_handle: str = "") -> str: + """Remove VML watermarks (e.g., DRAFT) from all document headers.""" + _, doc = _resolve(document_handle) + return _js(doc.remove_watermark()) @mcp.tool() -def audit_document() -> str: - """Run a comprehensive structural audit of the document. - - Checks: - - Footnote cross-references (references vs definitions) - - ParaId uniqueness and range validity - - Heading level continuity (no skips like H2 -> H4) - - Bookmark pairing (start/end matching) - - Relationship targets (all referenced files exist) - - Image references (all embedded images exist) - - Residual artifacts (DRAFT, TODO, FIXME markers) - - Returns a detailed report with an overall valid/invalid status. - """ - return _js(_require_doc().audit()) +def audit_document(document_handle: str = "") -> str: + """Run a comprehensive structural audit of the document.""" + _, doc = _resolve(document_handle) + return _js(doc.audit()) # ── Track changes ─────────────────────────────────────────────────────────── @@ -626,8 +579,9 @@ def insert_text( text: str, position: str = "end", author: str = "Claude", + document_handle: str = "", ) -> str: - """Insert text with Word track-changes markup (appears as a green underlined insertion in Word). + """Insert text with Word track-changes markup. Args: para_id: paraId of the target paragraph. @@ -635,7 +589,8 @@ def insert_text( position: Where to insert — "start", "end", or a substring to insert after. author: Author name for the revision (shown in Word's review pane). """ - return _js(_require_doc().insert_text(para_id, text, position=position, author=author)) + _, doc = _resolve(document_handle) + return _js(doc.insert_text(para_id, text, position=position, author=author)) @mcp.tool() @@ -643,38 +598,25 @@ def delete_text( para_id: str, text: str, author: str = "Claude", + document_handle: str = "", ) -> str: - """Mark text as deleted with Word track-changes markup (appears as red strikethrough in Word). - - Finds the exact text within the paragraph and wraps it in deletion markup. - The text must exist within a single run (formatting span). - - Args: - para_id: paraId of the target paragraph. - text: Exact text to mark as deleted. - author: Author name for the revision. - """ - return _js(_require_doc().delete_text(para_id, text, author=author)) + """Mark text as deleted with Word track-changes markup (red strikethrough in Word).""" + _, doc = _resolve(document_handle) + return _js(doc.delete_text(para_id, text, author=author)) @mcp.tool() -def accept_changes(author: str = "") -> str: - """Accept tracked changes — keep insertions, remove deletions. - - Args: - author: If set, only accept changes by this author. Empty = all changes. - """ - return _js(_require_doc().accept_changes(author=author or None)) +def accept_changes(author: str = "", document_handle: str = "") -> str: + """Accept tracked changes — keep insertions, remove deletions. Empty author = all.""" + _, doc = _resolve(document_handle) + return _js(doc.accept_changes(author=author or None)) @mcp.tool() -def reject_changes(author: str = "") -> str: - """Reject tracked changes — remove insertions, restore deleted text. - - Args: - author: If set, only reject changes by this author. Empty = all changes. - """ - return _js(_require_doc().reject_changes(author=author or None)) +def reject_changes(author: str = "", document_handle: str = "") -> str: + """Reject tracked changes — remove insertions, restore deleted text.""" + _, doc = _resolve(document_handle) + return _js(doc.reject_changes(author=author or None)) @mcp.tool() @@ -686,23 +628,12 @@ def set_formatting( underline: str = "", color: str = "", author: str = "Claude", + document_handle: str = "", ) -> str: - """Apply character formatting to text with tracked-change markup. - - Finds the text within the paragraph, splits the run if needed, and applies - formatting with rPrChange so it appears as a format revision in Word. - - Args: - para_id: paraId of the target paragraph. - text: Exact text to format. - bold: Apply bold formatting. - italic: Apply italic formatting. - underline: Underline style (e.g., "single", "double"). Empty = no underline. - color: Font color as hex (e.g., "FF0000"). Empty = no color change. - author: Author name for the revision. - """ + """Apply character formatting to text with tracked-change markup.""" + _, doc = _resolve(document_handle) return _js( - _require_doc().set_formatting( + doc.set_formatting( para_id, text, bold=bold, @@ -718,9 +649,10 @@ def set_formatting( @mcp.tool() -def get_comments() -> str: +def get_comments(document_handle: str = "") -> str: """List all comments with their ID, author, date, and text.""" - return _js(_require_doc().get_comments()) + _, doc = _resolve(document_handle) + return _js(doc.get_comments()) @mcp.tool() @@ -728,18 +660,11 @@ def add_comment( para_id: str, text: str, author: str = "Claude", + document_handle: str = "", ) -> str: - """Add a comment anchored to a paragraph. - - Creates the comment in comments.xml and adds range markers - (commentRangeStart/End) around the paragraph content. - - Args: - para_id: paraId of the paragraph to comment on. - text: Comment text. - author: Author name (shown in Word's comment sidebar). - """ - return _js(_require_doc().add_comment(para_id, text, author=author)) + """Add a comment anchored to a paragraph.""" + _, doc = _resolve(document_handle) + return _js(doc.add_comment(para_id, text, author=author)) @mcp.tool() @@ -747,30 +672,26 @@ def reply_to_comment( parent_id: int, text: str, author: str = "Claude", + document_handle: str = "", ) -> str: - """Reply to an existing comment (creates a threaded reply). - - Args: - parent_id: ID of the comment to reply to. - text: Reply text. - author: Author name. - """ - return _js(_require_doc().reply_to_comment(parent_id, text, author=author)) + """Reply to an existing comment (creates a threaded reply).""" + _, doc = _resolve(document_handle) + return _js(doc.reply_to_comment(parent_id, text, author=author)) # ── Save ──────────────────────────────────────────────────────────────────── @mcp.tool() -def save_document(output_path: str = "") -> str: +def save_document(output_path: str = "", document_handle: str = "") -> str: """Save all changes back to a .docx file. - Serializes modified XML parts and repacks into a DOCX archive. - Args: - output_path: Path for the output file. If empty, overwrites the original. + output_path: Path for the output file. If empty, overwrites the original + source path of the document under this handle. + document_handle: Handle of the document to save. Empty = `__default__`. """ - doc = _require_doc() + _, doc = _resolve(document_handle) path = output_path if output_path else None return _js(doc.save(path))