Skip to content

feat: support concurrent sessions via document handles#2

Open
FeritMelih wants to merge 1 commit intoSecurityRonin:mainfrom
FeritMelih:feat/parallel-session-handles
Open

feat: support concurrent sessions via document handles#2
FeritMelih wants to merge 1 commit intoSecurityRonin:mainfrom
FeritMelih:feat/parallel-session-handles

Conversation

@FeritMelih
Copy link
Copy Markdown

Summary

Replaces the single module-level _doc global with a dict keyed by an opaque document_handle, so multiple clients can edit different documents through one server process without clobbering each other's state.

The bug

server.py:25 held the currently-open document in a module global:

_doc: DocxDocument | None = None

Every tool read this via _require_doc(); four tools (open_document, close_document, create_document, create_from_markdown) mutated it. When two clients shared one stdio MCP connection (e.g. parallel agents from a single Claude Code session), opens raced — one client's paraId handles silently went stale, save_document retargeted to the peer's path, and get_document_info returned the wrong document's content. The "stale last output path on save_document(output_path='')" symptom was a side effect of DocxDocument.save(None) falling back to self.source_path on whatever instance _doc currently pointed at — not a separate cache.

The fix

  • Remove _doc. Add _docs: dict[str, DocxDocument] and a _DEFAULT_HANDLE = "__default__" constant.
  • Add a _resolve(handle) -> (key, doc) helper that replaces _require_doc().
  • Add an optional document_handle: str = "" parameter to all 45 @mcp.tool() functions.
  • open_document, create_document, create_from_markdown now return {"handle": key, ...info} so callers can capture and thread the handle through subsequent calls.

Backward compatibility

Empty-string handle resolves to __default__. Single-client callers that never pass a handle see identical behavior to before — same shared slot, same lifecycle. Parallel-aware clients pass a unique handle (e.g. a UUID) per session for isolation. No client needs to be updated to keep working.

Verification

Tested with two parallel agents under one Claude Code session, each editing a different file under a different handle:

  • Alpha (alpha-basic-test, test3_basic.docx) — 20 steps PASS
  • Beta (beta-redline-test, test4_redline.docx) — 22 steps PASS
  • Zero cross-session leakage: search_text for the peer's content from each side returned []
  • Real OOXML still produced: <w:ins>=2, <w:del>=1, <w:comment>=3 in beta's document
  • accept_changes still strips revision markup correctly: <w:ins>=0, <w:del>=0 after, <w:comment> preserved
  • Comments and tracked changes persisted across close_documentopen_document round-trips
  • No "No document is open" errors at any point

Before this change, the same parallel run had Beta's contract content silently overwriting Alpha's test1_basic.docx, Alpha's save_document writing to Beta's path, and Alpha's get_document_info returning Beta's stats.

Scope

One file changed (docx_mcp/server.py), no changes to any document/* mixin (they already operate on self, never globals). No changes to dependencies, packaging, or CLI.

Test plan

  • Existing single-client flow still works (empty handle → __default__)
  • Two clients with distinct handles edit two files concurrently with no leakage
  • open_document / create_document / create_from_markdown return the handle in their response
  • close_document only closes the document under the given handle
  • save_document(output_path="") saves to the correct per-handle source path

The server held the currently-open document in a single module-level
global (_doc), so two clients (e.g. parallel agents on one stdio
transport) editing different files would silently clobber each other's
state — paraIds going stale, save_document writing to the wrong path,
get_document_info returning the peer's content.

Replace the global with `_docs: dict[str, DocxDocument]` keyed by an
opaque `document_handle` string. Add an optional `document_handle`
parameter to all 45 tools. open_document / create_document /
create_from_markdown return the handle so callers can thread it through
subsequent calls.

Backward compatible: an empty handle resolves to a shared `__default__`
slot — identical behavior to the previous single-document semantics.
Single-client callers don't need to change anything; parallel-aware
clients pass a unique handle (e.g. a UUID) per session for isolation.

Verified end-to-end with two parallel test agents editing different
files under different handles: zero cross-session leakage, real OOXML
w:ins/w:del/w:comment elements still produced, accept_changes still
strips revision markup correctly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant