Plan: Conflict-Aware Merge in MCP Tools
Reduce content loss under concurrent writes by giving mark_publish a tool-level merge strategy. Disjoint edits succeed automatically via diff3; genuine overlap escalates to the calling agent's LLM with all three bodies in hand.
Motivation
Current mark_publish is strict optimistic concurrency: version mismatch → conflict error → caller re-fetches and republishes. Two failure modes at scale:
- Lost content — naive callers re-publish their stale body, clobbering the intervening writer
- Wasted retries — agents that edited disjoint paragraphs still take a full LLM round-trip to merge, even though a mechanical merge would have been clean
Eliminating the first 80% of conflicts mechanically (diff3) and giving the agent everything it needs for the last 20% (semantic merge) keeps both content loss and LLM cost low.
Non-Goals
- No DIFF verb. Versions are immutable; clients can compute diffs locally from any two FETCHes. Adding it to the wire format pulls processing into the server with no protocol-level benefit.
- No server-side merge. The server stays content-agnostic. Merge logic lives in the MCP tool (Go client code).
- No automatic semantic merge. The MCP tool has no LLM. Semantic resolution stays the calling agent's job.
- No changes to
mark_append. Append is an end-of-doc stream primitive by design; auto-resolve handles its tiny race window. Two concurrent appends serialize cleanly because there is nothing to merge — the server appends to whatever is current. Keep it untouched. - No backoff or fairness primitives in this plan. Livelock under sustained contention is real but is a schema problem (hot single-doc writes), not a merge problem. Address separately if it shows up.
Design
New parameter: on_conflict
Add an optional parameter to mark_publish:
on_conflict: "fail" | "merge"
"fail"— current behavior. Default for backward compatibility."merge"— attempt diff3 mechanical merge on conflict, escalate on overlap.
Merge Flow
When on_conflict: "merge" and the initial PUBLISH returns version mismatch:
- FETCH
/path/v{expected_version}— retrieve the base body (the version the agent built its edit from). Available because versions are immutable. - FETCH
/path— retrieve current body and current version. - diff3(base, ours, theirs) where:
base= body from step 1ours= body the agent passed tomark_publishtheirs= body from step 2
- If clean (no conflict markers): PUBLISH with
expected_version= the version from step 2's response. Return success withmerged: true. - If conflict markers: do not publish. Return a structured conflict response with the marked body for the agent's LLM to resolve.
The agent's original expected_version is the only version they ever provide. The merge loop manages all subsequent version handoffs internally — we always merge against whatever is currently latest at fetch time.
The PUBLISH in step 4 can itself conflict if a third writer slipped in between our fetch and our publish. Retry the full merge loop (re-fetch latest, re-diff3, re-publish) up to 3 times before escalating to the caller as a contention failure.
Conflict Marker Format
Git-style markers in the body. LLMs handle this natively from training data, no protocol commitment, format lives entirely in client/internal/merge/ Go code and can be swapped later without breaking anything.
some unchanged text
<<<<<<< ours
agent A's version of the line
=======
agent B's version of the line
>>>>>>> theirs
more unchanged text
Response Shapes
Success after merge (status: ok):
status: ok
version: 7
merged: true
base-version: 5
their-version: 6
Escalation on overlap (status: conflict):
status: conflict
your-version: 5
current-version: 6
mergeable: false
Body contains the diff3 output with git-style conflict markers, so the agent can either:
- Send the marked body directly to its LLM for resolution
- Or use the metadata to refetch base/theirs separately
Escalation on contention (status: conflict, after 3 retries):
status: conflict
your-version: 5
current-version: 9
mergeable: false
reason: contention
retries: 3
Wire-Level Impact
Zero. on_conflict is an MCP tool parameter handled in the Go client. Server protocol unchanged. Versioned FETCH (/path/vN) already exists.
Implementation
Where the code lives
- MCP handler:
client/internal/mcp/(or wherevermark_publishis dispatched). Add theon_conflictparameter to the tool schema and route to a merge helper. - Merge helper: new package
client/internal/merge/with:Diff3(base, ours, theirs string) (merged string, hadConflict bool)— pure function, easy to testMergeAndPublish(ctx, client, path, ours, expectedVersion int, maxRetries int) (Result, error)— orchestrates the FETCH/FETCH/diff3/PUBLISH loop
- Diff3 algorithm: use a vetted Go library if one exists (search
github.com/...diff3), otherwise implement Myers-based three-way merge. Keep behind theDiff3function so it's swappable.
Test coverage
Table-driven tests in client/internal/merge/:
- Disjoint paragraph edits → clean merge
- Same-line edits → conflict markers
- Adjacent line edits → clean merge (verify diff3 doesn't over-conflict)
- One side adds, other side deletes → expected diff3 behavior documented
- Empty
oursortheirs→ handled gracefully MergeAndPublishretry exhaustion → returns contention statusMergeAndPublishsuccess on second try → returns merged: true with correct versions
Integration test against a real server: two clients both edit the same doc in different sections, both call on_conflict: "merge", both succeed, final doc contains both edits.
Backward compatibility
on_conflict defaults to "fail". Existing callers see no behavior change. New callers opt in.
Open Questions
- Should the tool return the merged body to the caller on success, or just metadata? Returning the body lets the agent verify the merge result; not returning it keeps the response small. Leaning toward returning it — verification is cheap and the agent already pays the bandwidth cost of having sent its own body in the request.
Out of Scope (Future)
mark_update_section— heading-scoped writes that further reduce contention surface. Higher leverage than this plan but bigger change. Track separately.- Server-side hot-path detection / batching window — backpressure for sustained contention. Only build if contention shows up in practice.
- Soft leases — agent reserves a path for N seconds. Fairness primitive, not a merge primitive.
Success Criteria
- Two agents editing different paragraphs of the same document with
on_conflict: "merge"both succeed without LLM involvement. - Two agents editing the same line both receive structured conflict responses with git-style markers in the body.
- No change to existing callers using default
on_conflict: "fail". mark_appenduntouched and behavior unchanged.- Diff3 helper has full branch coverage in unit tests.
- Integration test with three concurrent writers terminates with all writes reflected in final doc (or one mergeable conflict, never silent loss).
Decisions Log
- 2026-05-05: Conflict markers are git-style in body. Tool-side only, swappable, no protocol commitment.
- 2026-05-05: Retry count default is 3.
- 2026-05-05:
mark_appendis out of scope. Append-as-stream is the right primitive there; auto-resolve already handles its race window. - 2026-05-05: Merge always runs against current latest at fetch time. Caller's
expected_versionis the original fetch only; the merge loop owns all subsequent version handoffs.