2026-05-21
Broker MCP Gateway — Slice 2 (read tools + session cache foundation)
Branch: feat-tools-broker-mcp-gateway-read-tools.
Built the read-side of the gateway: lazy-minted, per-email cached world tokens drive mark_fetch / mark_list / mark_versions against the worldPool. The remaining 10 tools still hit notImplementedHandler. Plan reference: /plans/broker-https-gateway.md v5.
Decisions made this session that weren't in the plan
-
Session cache is keyed by canonical verified email. Plan v4 was subject-hash-keyed; investigated the existing Issuer.MintFiltered and found the broker already canonicalizes email and uses it as the ownership key across
/auth/callback,/me/install,/tokenslist/revoke, and audit logs. Two identity dimensions in one broker would have been confusing. Published v5 of the plan pinning email-keyed session cache as a Non-Negotiable, and updated the slice accordingly. Subject claim stays in id_token for log correlation but is not the cache key. -
OQ#9 resolution: retry-on-401-after-mint with exponential backoff. The kubelet → world Secret propagation lag means a freshly-minted token can land at the world before the projected-volume refresh has run; the world then 401s. Considered three options:
- Sync wait via a world-side
/health/tokens?hash=...endpoint → rejected (violates "no demarkus-server changes" Non-Negotiable). - Prewarm at MCP
initialize→ rejected (front-loads SIGHUPs for worlds the user may never touch). - Retry-on-401 with exponential backoff (chosen) — broker-only, narrow blast radius, matches
mutateSecret's optimistic-concurrency posture.
Defaults: 6 attempts, 250ms → 8s exponential, ~16s of total waiting across attempts. Configurable via
cfg.Server.MCP.FirstMintMax*. Distinguishes "fresh mint" 401s (retry with backoff — propagation race) from "cache hit" 401s (invalidate + immediately re-mint without sleeping — operator hand-revoked, sweeper retired, etc.). - Sync wait via a world-side
-
Singleflight on
(canonicalEmail, worldName)mints. Concurrent first-call bursts for the same identity+world coalesce to one Issuer round-trip. Mitigates the "mint storm" risk on broker restart (every active plugin reconnects and re-mints simultaneously).golang.org/x/sync/singleflightadded as a direct dep ontools/. -
MCPGatewayWith(version, dispatcher)test seam. ProductionMCPGateway()builds a real*worldPool(and registers it onServer.mcpPoolsoCloseMCPGateway()drains pooled QUIC connections during shutdown). Tests pass afakeDispatcherto drive handlers without standing up a real QUIC server. AddsServer.CloseMCPGateway()whichmain.gocalls aftermcpSrv.Shutdownso in-flight tool calls finish before connections close. -
formatToolResultduplicated (not hoisted) fromclient/cmd/demarkus-mcp/formatResult. 20-line helper, stable. AformatResultReferencecopy inmcp_tools_read_test.gois asserted byte-equal toformatToolResultfor severalfetch.Resultcases — that's the proxy-fidelity gate. If the local helper drifts, the parity test breaks before shipping; that's when hoisting toclient/mcpfmtbecomes the right call. -
Tool URL shape rejects triple-slash (
mark:///foo).parseToolURLonly accepts the canonicalmark://{worldName}/{path}form. Considered fallback-parsingmark:/team-a/foo(single-slash, world-in-path) but rejected — silently re-interpreting the first path segment as the worldName would mask agent typos. Strict shape, clear error. -
tools/module nowreplacesclient/. Pre-Flight 0 already hoistedclient/fetchandclient/mergeto public, but the broker module didn't have the replace directive in place because Slice 1 didn't yet import client packages. Added in this slice;go mod tidythen promotedclientandx/syncto direct deps.
Bugs found during testing
- Session-cache clock vs issuer clock.
newIssuer(t, ...)pins the issuer's clock to 2026-05-11 (so tokens expire 2026-05-12). The gateway's session cache usesServer.clockwhich defaults totime.Now. Result: every minted token'sexpiresAtwas already in the past by the broker's clock, so the cache treated every entry as instantly expired. Fix innewGatewayWithDispatcher: pinsrv.clockto the same date asnewIssuer. Two tests (TestHandleMarkFetchCacheHitReusesToken,TestDispatchReadCacheHit401InvalidatesAndReMints) caught it; both pass after the clock alignment. - Singleflight test deadlock. Initial design used
select { case ready <- struct{}{}: default: }to signal the test the mint was in flight, but with select-default the very first sender would lose the signal if the main test wasn't yet reading. Main then blocked forever on<-ready. Fix: bufferedreadychannel (size 1). Pinned in the test comment. - LRU eviction test using
GetOrMintas a probe was self-defeating. Each "is this session still cached?" check viaGetOrMinteither re-minted (mutating LRU) or moved the session to the LRU front, perturbing the very state under inspection. Switched to a direct read ofc.sessionsunder the mutex.
Scope outcome vs plan estimate
- Production code: ~1100 LOC (plan estimate: ~400). Overshoot mostly in the retry loop + mintFuncFor closure + formatter duplication + worldPool dispatcher seam.
- Test code: ~1224 LOC (plan estimate: ~500). Many edge cases — parseToolURL, propagation-race retry exhaustion, cache-hit 401 invalidation, byte-for-byte parity. Worth it.
go test -race ./...green across all modules.pre-commit.sh(fmt + vet + golangci-lint across protocol/server/client/tools) green.
Next session — Slice 3 starting point
feat-tools-broker-mcp-gateway-write-toolsbranch.mark_publish/mark_append/mark_archivehandlers inmcp_tools_write.go. ReusesessionCache.GetOrMint+worldDispatcher(extend interface with Publish/Append/Archive methods onworldPool).- Conflict envelope follows local server's shape (
expected_version,on_conflict: "fail"for Slice 3 — merge-candidate is Slice 6). - World-side RBAC failure (
not-permitted) surfaces as MCP tool error without retry (unlikeunauthorizedwhich triggers the propagation-race retry loop).
Broker MCP Gateway — Slice 3 (write tools)
Branch: feat-tools-broker-mcp-gateway-write-tools. Slice 2 (PR #146) merged onto main as 7c529d4.
Lit up the three write verbs (mark_publish, mark_append, mark_archive). All 6 of the 13 tools are real handlers now; the 7 federation tools still hit notImplementedHandler until Slices 4–5.
Decisions made this session that weren't in the plan
- Refactored
dispatchRead→dispatchWithAuth(ctx, claims, worldName, op worldOp). Closure-based: handlers capture per-verb args (path, body, expected_version, meta) in the closure; the retry loop only knows about token. ThereadOpenum +dispatchOpswitch are gone. This was load-bearing for Slice 3 — without the refactor, writes would need their own retry loop and the cache-hit-401 + propagation-race semantics could silently diverge. Slice 4–5 federation tools will share the same dispatch path. on_conflict="merge"rejected with explicit Slice 6 pointer. Plan says "Slice 3 ships on_conflict=fail only; merge-candidate is Slice 6." The cleanest agent UX is: rejectmergeoutright with a tool-error message that names Slice 6. Silently treatingmergeasfailwould be a bait-and-switch — the agent thinks merge is being attempted when it isn't, and the world's response shape wouldn't carry the merge-candidate body the agent's prompt expects. Default flipped tofailfor Slice 3; will flip back tomergewhen Slice 6 lands the candidate flow.- Publisher metadata derives
agentfrom the canonical email, not the MCP session's client info. The local demarkus-mcp usesmcpserver.ClientSessionFromContext(ctx)to get the MCP client name (e.g. "claude-code"). The broker doesn't have session-level client info on the tool-call context (the MCP layer's session info is one transport up). UsingcanonicalEmail(claims.Email)instead matches the broker's existing audit log identity dimension — so a write'sagentfield correlates with the broker's/tokensand/me/installaudit entries. Operators reading world-side audit logs see a real identity instead of a generic "broker." - Append auto-resolve via
dispatchWithAuth(VERSIONS) → APPENDruns both calls through the same dispatch wrapper. Each call gets independent retry-on-401 budget. The singleflight + sessionCache from Slice 2 ensure the VERSIONS call and the APPEND call share whatever's already cached — typical case: one mint, two dispatches. - Compile-time guard added:
var _ mcpserver.ToolHandlerFunc = (*mcpGateway)(nil).handleMarkPublish(and append + archive). Catches a signature regression at build time instead of at AddTool registration time, where it would be a runtime nil-handler. Three lines, zero cost; same pattern would be cheap to extend across all 13 tools in a follow-up. - Conflict + not-permitted forward verbatim, not as tool errors. Slice 2 surfaced
unauthorizedas a retry signal AND eventually as a tool error if retries exhausted. For writes,conflictandnot-permittedare first-class world responses the agent acts on (re-fetch and retry, ask the operator for more scope, etc.). The plan's byte-for-byte proxy contract says forward them verbatim — that meansisError: falseon the MCP envelope; the "status: conflict" line in the formatted text is the signal. Tests pin this for both conflict (publish with version mismatch) and not-permitted (write op against a read-only token).
Scope outcome vs plan estimate
- Production code: ~250 LOC new (mcp_tools_write.go) + ~140 LOC modified (dispatchWithAuth refactor, worldDispatcher interface extension, worldPool method additions, gateway wiring, gateway test pivot to mark_discover). Total prod ~390 LOC vs plan ~250. On the high side but the refactor was load-bearing.
- Test code: ~543 LOC new (mcp_tools_write_test.go) + ~60 LOC modified (fakeDispatcher extended with publish/append/archive scripted fns + writeCall record type). Total tests ~600 LOC vs plan ~400. Many edge cases — explicit version, auto-resolve, missing body/version, conflict pass-through, not-permitted pass-through, transport failure, on_conflict=merge rejection, on_conflict=unknown rejection, retry-inheritance, end-to-end via Streamable HTTP.
go test -race ./...green across all 4 modules.pre-commit.sh(fmt + vet + golangci-lint × 4) green.
Next session — Slice 4 starting point
feat-tools-broker-mcp-gateway-federation-readbranch.mark_discover/mark_resolve/mark_backlinks/mark_graphhandlers inmcp_tools_federation.go.- Federation tools delegate to whichever client library functions back the local MCP server's federation surface. Per the plan: "second-pass spike at start of Slice 4" to figure out the public API shape — Pre-Flight 0 hoisted
client/fetchandclient/merge, but federation may need additional hoists (client/internal/index,client/internal/graphstore,client/internal/graph,client/internal/links). Worth doing the spike before coding. - These tools are read-only and don't change the session-cache shape; they reuse
dispatchWithAuthfor any tool that requires a world token, and pure local operations (e.g.mark_backlinksagainst the broker's own graph store) skip dispatch entirely.
Broker MCP Gateway — Slice 4a (federation reads: discover + resolve)
Branch: feat-tools-broker-mcp-gateway-federation-discover-resolve. Slice 3 (PR #147) merged onto main as 5387701.
Lit up the two federation read tools that don't need a broker-side graph store: mark_discover (well-known manifest fetch) and mark_resolve (hash-indexed content lookup). All 8 of 13 tools are now real handlers; the 5 placeholders are mark_backlinks + mark_graph (Slice 4b — graph-store infrastructure deferred) and mark_index + mark_graph_export + mark_graph_publish (Slice 5).
Decisions made this session that weren't in the plan
- Split Slice 4 into 4a and 4b. Plan v5 grouped all four federation reads (discover, resolve, backlinks, graph) in one slice with a ~200 LOC estimate. The spike (task #16) found that backlinks + graph each need a broker-side graph store, plus hoists of
client/internal/graphstore(~579 LOC) +client/internal/graph+client/internal/links(~700 LOC of new public surface). That doesn't fit a single slice cleanly, and the broker-side graph store is itself a design question (persistent vs ephemeral vs defer-to-local-demarkus-mcp) that deserves its own slice's worth of attention. Discover + resolve are clean byte-for-byte proxies; they ship in 4a per the plan's stated scope estimate. Recorded the split decision for the plan's Implementation Status section. - Cross-org candidate skip semantics. A
mark_resolveindex entry pointing at a server URL the broker has no WorldConfig for surfaces fromIssuer.MintFilteredasErrNotAuthorized(noterrWorldNotFound—errWorldNotFoundonly fires insideworldPool.clientFor, which is downstream of the mint). Both errors collapse to "this broker can't reach this candidate for me" from the agent's perspective, soresolveCandidatetreatserrors.As(_, *errWorldNotFound) || errors.Is(_, ErrNotAuthorized)as a single skippable reason class. Different internal causes (world unknown vs. world known but identity unauthorized), same recovery: try the next candidate. The skip message names the server URL so a debugging operator can correlate. - mark_discover requires
url(broker has no default-host fallback). The localdemarkus-mcphas an optionalurlparam because it can be pinned to a single host via-host. The broker addresses worlds by name and has no equivalent default — every tool call carries the worldName. Broker's mark_discover tool definition already required url (Slice 1); the handler matches. indexBodyhelper in tests builds index documents inline. Could have calledclient/index.Builddirectly, butBuildadds a Source/Indexed/Documents header that the tests don't care about and that would couple test fixtures to the index package's metadata schema. The 5-line inline table builder is cleaner and decouples test intent from index format evolution.
Bugs found during testing
TestHandleMarkResolveCrossOrgCandidateSurfacesAsSkippedReasoninitially failed because I expected the cross-org dispatch to hit my fake (which returnserrWorldNotFound) but the mint step ran first and returnedErrNotAuthorizedbefore the dispatcher was ever called. Real fix in production code (resolveCandidate now handles both errors), not just the test. Worth pinning the test against the actual code path the production worldDispatcher would produce; not "patching the test to match the bug."- golangci-lint gocritic
unnamedResultonresolveCandidate's two-string return. Named the results(result fetch.Result, skipReason string)so the call site reads obviously.
Scope outcome vs plan estimate
- Pre-Flight (client/internal/index → client/index hoist): 2 file moves + 2 import updates (5 LOC of edits total). Smaller than Pre-Flight 0's fetch/merge hoist because the index package has only two consumers.
- Production code: ~190 LOC new (mcp_tools_federation.go) + ~17 LOC modified (mcp_gateway.go toolHandlers + gateway_test placeholder canary pivot to mark_backlinks). Total prod ~207 LOC, matches the plan's ~200 LOC estimate for the original Slice 4. Splitting off backlinks+graph kept the slice tight.
- Test code: ~470 LOC new (mcp_tools_federation_test.go). Vs plan ~300 LOC. Heavier than estimated because the resolve verb has many edge cases: invalid hash, missing index, index-not-found, index-non-ok, hash-not-in-index, content-hash mismatch (multi-candidate skip), cross-org skip, all-candidates-fail (last-failure naming), auth-retry inheritance. Each test pins one explicit semantic.
go test -race ./...green across all 4 modules.pre-commit.sh(fmt + vet + golangci-lint × 4) green.
Next session — Slice 4b OR Slice 5 starting point
Two paths forward; the design question on Slice 4b is real and worth deciding deliberately:
Slice 4b — mark_backlinks + mark_graph: needs a broker-side graph store. Three options:
- Persistent: filesystem-backed store under broker's PVC. Heaviest — chart values, RBAC, lifecycle. Closest parity with local demarkus-mcp.
- Ephemeral in-memory: lives in process, drops on restart. Lightweight; user must re-crawl after broker bounces. Acceptable for read-mostly federation views.
- Defer to local demarkus-mcp: broker returns "graph functionality requires the local demarkus-mcp; install it via /soul-init" tool error. Cheapest but breaks the "13-tool parity" Non-Negotiable.
Recommendation: lead with option 2 (ephemeral) for Slice 4b — fits the broker's "wire-shape adapter" framing better than persistent state, keeps parity intent intact, and a persistent store is an easy follow-up if real customers ask. Worth getting Fritz's sign-off before coding.
Slice 5 — mark_index + mark_graph_export + mark_graph_publish: mark_index is independent of the graph store (it crawls + builds an index document via index.Build, then publishes to a target). mark_graph_export + mark_graph_publish both read from the graph store, so they're really Slice 4b territory.
Honest recommendation: decide 4b's graph-store path first, then ship 4b + 5 together — they share the graph-store dependency and a unified slice keeps the broker-side state-management story in one PR. Skipping 4b and shipping just mark_index in Slice 5 is possible but leaves the graph tools dangling for an extra cycle.