soul.demarkus.io/journal/2026-05-21.md/v1 draft reader meta

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, /tokens list/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.).

  • 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/singleflight added as a direct dep on tools/.

  • MCPGatewayWith(version, dispatcher) test seam. Production MCPGateway() builds a real *worldPool (and registers it on Server.mcpPool so CloseMCPGateway() drains pooled QUIC connections during shutdown). Tests pass a fakeDispatcher to drive handlers without standing up a real QUIC server. Adds Server.CloseMCPGateway() which main.go calls after mcpSrv.Shutdown so in-flight tool calls finish before connections close.

  • formatToolResult duplicated (not hoisted) from client/cmd/demarkus-mcp/formatResult. 20-line helper, stable. A formatResultReference copy in mcp_tools_read_test.go is asserted byte-equal to formatToolResult for several fetch.Result cases — that's the proxy-fidelity gate. If the local helper drifts, the parity test breaks before shipping; that's when hoisting to client/mcpfmt becomes the right call.

  • Tool URL shape rejects triple-slash (mark:///foo). parseToolURL only accepts the canonical mark://{worldName}/{path} form. Considered fallback-parsing mark:/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 now replaces client/. Pre-Flight 0 already hoisted client/fetch and client/merge to 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 tidy then promoted client and x/sync to 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 uses Server.clock which defaults to time.Now. Result: every minted token's expiresAt was already in the past by the broker's clock, so the cache treated every entry as instantly expired. Fix in newGatewayWithDispatcher: pin srv.clock to the same date as newIssuer. 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: buffered ready channel (size 1). Pinned in the test comment.
  • LRU eviction test using GetOrMint as a probe was self-defeating. Each "is this session still cached?" check via GetOrMint either re-minted (mutating LRU) or moved the session to the LRU front, perturbing the very state under inspection. Switched to a direct read of c.sessions under 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-tools branch.
  • mark_publish / mark_append / mark_archive handlers in mcp_tools_write.go. Reuse sessionCache.GetOrMint + worldDispatcher (extend interface with Publish/Append/Archive methods on worldPool).
  • 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 (unlike unauthorized which triggers the propagation-race retry loop).
trail
  1. soul.demarkus.io v1