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 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. - 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 with amerged: truefield so the caller knows a merge happened. - If conflict markers: do not publish. Return a structured conflict response (see below) for the agent's LLM to resolve.
The PUBLISH in step 4 can itself conflict if a third writer slipped in. Retry up to N times (default 3) before escalating to the caller as a contention failure.
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 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 retries exhausted):
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 dance
- Diff3 algorithm: use a vetted Go library if one exists (search
github.com/...diff3), otherwise implement the Myers-based three-way merge. Keep the implementation in one file behind theDiff3function so it can be swapped.
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
- Conflict marker format. Use git-style
<<<<<<< / ======= / >>>>>>>or a JSON-friendly structured form? Git-style is universally readable by LLMs but uglier in markdown; structured form is cleaner but the agent has to parse it. - Should the tool return the merged body to the caller on success, or just metadata? Returning the body lets the agent verify; not returning it keeps the response small.
- Default retry count. 3 feels right but pick after observing real workloads. Higher under contention pushes cost onto the unlucky agent.
mark_append: should it also gainon_conflict: "merge"? Append already auto-resolvesexpected_version, so it rarely conflicts. Probably not worth the complexity until data shows otherwise.
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 all three bodies retrievable.
- No change to existing callers using default
on_conflict: "fail". - Diff3 helper has 100% 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).