2026-05-22
Broker MCP Gateway — Slice 6 (conflict-aware merge in mark_publish)
Branch: feat-tools-broker-mcp-gateway-merge. Slice 4b+5 (PR #149) merged onto main as fa9f86d.
Lit up the on_conflict="merge" candidate flow in mark_publish. The broker now has full semantic parity with the local demarkus-mcp's publish surface (modulo the documented ephemeral graph-store gap from Slice 4b). After Slice 6: only Slice 7 (chart + RBAC + docs) and Slice 8 (/knowledge-join plugin slash command) remain before the broker MCP gateway plan is done.
Decisions made this session that weren't in the plan
brokerMergeAdaptercapturesctxin the struct. Normally a code smell, butmerge.Client's interface (FetchVersion / FetchCurrent / Publish) doesn't take ctx — the package was designed againstfetch.Clientwhich also doesn't take ctx. The adapter's lifetime is bounded by onehandleMarkPublishcall, so capturing the handler's ctx in the struct is the load-bearing alternative to losing ctx.Done propagation across the 3-step orchestration. Same shape the local demarkus-mcp'smergeClientAdapteruses against itsmarkClientinterface.- Default
on_conflictflipped to"merge"to match local demarkus-mcp. Slice 3's default was "fail" because the merge code didn't exist yet; Slice 6 flips it. The shift means agents callingmark_publishwithout specifyingon_conflictnow get the safety-net merge-candidate flow on conflict instead of a raw conflict envelope. Aligns with the local server's "convenience that hides necessary complexity creates bugs" thought from/thoughts.md— merge as default is the safer footgun-free choice. optionalIntMeta(vs the localoptionalInt) to avoid name collision. The broker package may grow a future sharedoptionalInthelper used by other handlers; naming this meta-specific avoids reserving the simpler name unnecessarily. Same behavior as the local server's helper.formatMergeOutcomeis duplicated, not hoisted. Same proxy-fidelity story asformatToolResultfrom Slice 2: 25 LOC, stable, byte-for-byte parity with the local server'sformatOutcome. If they ever drift, the existing proxy-fidelity reference test inmcp_tools_read_test.gocatches theformatToolResultside; the merge-specific format isn't covered by that test (yet — could be extended), but the function is simple enough that drift would be visible immediately.
Real bugs surfaced during testing
- Append + auto-resolve happy-path test had
"server-version": "1.0"in fixture data. Worked under Slice 3 because the "fail" branch forwarded the metadata verbatim without parsing. Slice 6's merge path runsoptionalIntMetaover server-version, whichstrconv.Atoi-rejects floats. Fixed the fixture to"1"(matches actual server output). The other append-side tests using "1.0" still work because mark_append's success path doesn't parse server-version — only mark_publish via merge. TestHandleMarkPublishConflictPassesThroughVerbatimhad to be renamed + opt intoon_conflict="fail". Previously asserted the default behavior; now the verbatim-conflict semantics only apply to the explicit "fail" branch since "merge" is default. Renamed toTestHandleMarkPublishFailConflictForwardsVerbatimto make the intent obvious. The test still exists and pins the still-supported opt-out behavior.TestHandleMarkPublishOnConflictMergeRejectedUntilSlice6deleted outright — the property it pinned (merge rejection) no longer applies. Replaced byTestHandleMarkPublishMergeCleanOutcomeOK+TestHandleMarkPublishDefaultOnConflictIsMerge+ 5 other new tests covering the merge surface end to end.- First version of the candidate-without-markers test had the wrong base/ours/theirs triple. Both sides appended a different line after a shared base line — Diff3 flagged that as overlapping because both ended the file with a new value. Rewrote the test with truly disjoint edits (ours edits line 1, theirs edits line 4 of a 4-line document) so Diff3 produces the clean structural merge. Worth pinning in the test comment: line-level disjoint at the ENDS of the file looks the same as overlapping edits to Diff3.
Scope outcome vs plan estimate
- Production code: +163 LOC in
mcp_tools_write.go(brokerMergeAdapter + 3 methods + helpers + merge branch + formatMergeOutcome). Plan estimate ~150 LOC. ✅ - Test code: +375 LOC in
mcp_tools_write_test.go(3 deleted/replaced, 7 new). Plan estimate ~250 LOC. Over by ~50% but covers real distinct semantics — every test pins one explicit invariant (clean OK, no-markers, markers, auth-retry inheritance via shared tokens, default-flip, whitespace-normalization, base-version-zero). Worth it. go test -race ./...green across all 4 modules.pre-commit.sh(fmt + vet + golangci-lint × 4) green.
Surface status after Slice 6
All 13 broker MCP tools have full semantic parity with the local demarkus-mcp:
- 6 verbs (fetch/list/versions/publish/append/archive) with proxy fidelity + auth-race retry.
- 2 federation reads (discover/resolve).
- 5 graph-store tools (backlinks/graph/index/graph_export/graph_publish) backed by an ephemeral per-pod store.
The only documented behavioral gap is the graph-store ephemerality (re-crawl after broker restart). Everything else — including the merge-candidate envelope — is byte-for-byte indistinguishable between brokered and direct-QUIC access.
Next session — Slice 7 starting point
Slice 7 is the chart, RBAC, docs work. Per the plan:
deploy/helm/demarkus-broker/values.yaml—server.mcp.addr,server.mcp.tls.existingSecretRef,server.mcp.sessionMaxIdle,server.mcp.worldTokenTTL,server.mcp.worldPool.worlds[].internalAddressfor non-default Service DNS overrides.- Chart templates: deployment.yaml (new containerPort + TLS mount), service.yaml (gateway port), ingress.yaml (route mcp host to new port), networkpolicy.yaml (allow ingress on mcp port).
deploy/helm/demarkus-broker/README.md— "MCP gateway" section. TLS setup, plugin flow, OAuth flow, rate-limit behavior, ephemeral graph-store note.tools/demarkus-broker/MCP-API.md— operator/developer-facing spec for the 13-tool MCP surface.- Upgrade note in chart README: pre-gateway deployments pick up the listener on
:8081after the chart bump.
This is the slice that unlocks the kind-harness sanity testing for the MCP gateway — the chart changes are the unlock. Once Slice 7 ships, the deploy/kind harness can grow a --with-mcp-smoke stage that drives /mcp via curl + JSON-RPC against a real demarkus-server world. That's the end-to-end "Done When" criterion from the plan.
Slice 8 (/knowledge-join plugin slash command) is small (~50 LOC + tests) and lands last.