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.