fix: implement MCP Streamable HTTP transport for memory client - #71
Conversation
Fixes unbound-force#19 — memory.Client.Call() does not speak MCP Streamable HTTP. Artifacts: - proposal.md: motivation, capabilities, impact, constitution alignment - design.md: 7 design decisions (mcpclient package, lazy init, concurrency safety, dual-format parsing, configurable identity/timeout, logging) - specs/mcp-transport.md: 9 ADDED requirements with 18 Given/When/Then scenarios, coverage strategy table - tasks.md: 4 task groups with TDD ordering Review council findings addressed: - Thread safety for shared session state (CRITICAL) - Package naming: mcpclient/ not mcphttp/ to avoid mcp/ collision - Dual-format response parsing (SSE + plain JSON) - SSE edge cases (empty body, malformed JSON, no data line) - Initialize failure scenarios - Concurrency safety scenarios - Configurable client identity and timeout - Observability via optional structured logging - Coverage strategy with 17 required test paths
Extract shared MCP client into internal/mcpclient/ package with: - MCP session lifecycle (initialize handshake + Mcp-Session-Id) - tools/call envelope wrapping (transparent to callers) - Dual-format response parsing (SSE and plain JSON) - Session recovery on HTTP 400/404 (reset + retry once) - Thread-safe via sync.Mutex, 87.2% test coverage Rewrite memory.Client to delegate to mcpclient.Client. Migrate doctor's deweyHealthProbe() to use shared client. All existing tests adapted with MCP-aware mock handlers. Fixes unbound-force#19
- Replace strings.NewReader(string(body)) with bytes.NewReader(body) - Add explicit Content-Type check before SSE parsing fallthrough - Remove unused newMCPServer allocation in test
- MCP Streamable HTTP transport pattern - mcpclient shared architecture decisions - Review council spec review patterns Assisted-by: claude-opus-4-6 Generated with AI assistance (claude-opus-4-6)
cf2bc42 to
cdff0af
Compare
yvonnedevlinrh
left a comment
There was a problem hiding this comment.
Review: fix: implement MCP Streamable HTTP transport for memory client
Verdict: REQUEST CHANGES — 4 findings require attention (2 mechanical, 2 logic). The core design is sound and all acceptance criteria from #19 are met.
What works well
- Clean extraction of
internal/mcpclient/— 340 lines of production code, 836 lines of tests, 20+ scenarios - Correct MCP session lifecycle:
initialize→Mcp-Session-Id→tools/call wrapError()bridge preserves backward compatibility for all existingerrors.Ascallers- Dual-format SSE + JSON response parsing is spec-compliant
deweyHealthProbe()reduced from ~60 lines to 6 by delegating to the new client- All 8 production callers verified compatible — no semantic breakage
Findings requiring changes
| # | Severity | Category | Summary |
|---|---|---|---|
| 1 | MEDIUM | Correctness | Non-UnavailableError responses bypass graceful degradation |
| 2 | MEDIUM | Governance | Missing CI coverage ratchet for internal/mcpclient/ |
| 3 | MEDIUM | Concurrency | Session recovery TOCTOU window between unlock and retry |
| 4 | MEDIUM | Convention | Import ordering violation (bytes after net/http) |
Informational findings (no changes required)
| # | Severity | Summary |
|---|---|---|
| 5 | LOW | Hardcoded "1.0.0" version with no link to binary version |
| 6 | LOW | UnavailableError missing Is() method — fine since all callers use errors.As |
| 7 | LOW | Test mock doesn't validate Mcp-Session-Id matches issued session |
| 8 | LOW | parseResponse treats empty Content-Type as SSE without documenting the behavior |
Caller impact
All production call sites traced and verified:
tools/memory/tools.go—hivemindStore,hivemindFind(useerrors.As✓)memory/proxy.go—Health(),Store(),Find()(delegate toCall✓)doctor/checks.go—deweyHealthProbe(treats any error as warn ✓)cmd/replicator/serve.go,docs.go—NewClientconstructor (no API change ✓)
Reachability
- Session recovery 400/404 gate: correctly reachable only on HTTP-level errors, never on JSON-RPC errors inside a 200. ✓
sessionID != ""guard: reachable when server omitsMcp-Session-Idheader. Tested. ✓"data:"without space: unreachable from known servers but correct per SSE spec. Defensive code. ✓doToolsCallmarshal/request-creation errors: practically unreachable — defensive guards. ✓
Finding 2 — Missing CI coverage ratchet: internal/mcpclient/ has no entry in the THRESHOLDS array in .github/workflows/ci.yml. The spec (mcp-transport.md) requires ≥80% line coverage. Add ["internal/mcpclient"]=80 to the ratchet step. This file is not in the PR diff, so the author should include it as a follow-up commit.
…x imports - Wrap parseToolsCallResponse errors as *UnavailableError so they propagate correctly through memory.wrapError() (errors.As match) - Hold mutex across session recovery retry to prevent concurrent goroutines from triggering redundant re-initialization (TOCTOU fix) - Fix stdlib import ordering: move bytes before encoding/json (CS-002) - Add comment documenting intentional empty Content-Type fallthrough Addresses PR unbound-force#71 review feedback from @yvonnedevlinrh. Signed-off-by: Jay Flowers <jay@unboundforce.com> Assisted-by: claude-opus-4-6
- Add Mcp-Session-Id request header validation in mock handler to verify clients send the correct session ID (TC-009) - Add clarifying comment on proxy.go version constant noting it is the MCP protocol version, not the binary version Addresses PR unbound-force#71 review feedback from @yvonnedevlinrh. Signed-off-by: Jay Flowers <jay@unboundforce.com> Assisted-by: claude-opus-4-6
Add internal/mcpclient to the THRESHOLDS array with 80% minimum line coverage, matching the spec requirement for new packages. Addresses PR unbound-force#71 review feedback from @yvonnedevlinrh. Signed-off-by: Jay Flowers <jay@unboundforce.com> Assisted-by: claude-opus-4-6
Address review-council findings: - Wrap marshal/request-creation errors in UnavailableError for consistency - Fix misleading version comment in proxy.go (client version, not protocol) - Remove unused initResponse/toolResponse mock fields Addresses PR unbound-force#71 review feedback. Signed-off-by: Jay Flowers <jay@unbound.force> Assisted-by: claude-opus-4-6
|
Re: Finding 2 (CI coverage ratchet) — Addressed in |
Add test for UnavailableError.Unwrap() method which was at 0% coverage, causing CI function-average coverage to drop to 79.3% (below 80% threshold). With this test, Unwrap() is at 100% and the function-average is ~91.8%. Addresses PR unbound-force#71 CI failure in "Enforce Coverage Ratchets" step. Signed-off-by: Jay Flowers <jay@unbound.force> Assisted-by: claude-opus-4-6
…x imports - Wrap parseToolsCallResponse errors as *UnavailableError so they propagate correctly through memory.wrapError() (errors.As match) - Hold mutex across session recovery retry to prevent concurrent goroutines from triggering redundant re-initialization (TOCTOU fix) - Fix stdlib import ordering: move bytes before encoding/json (CS-002) - Add comment documenting intentional empty Content-Type fallthrough Addresses PR #71 review feedback from @yvonnedevlinrh. Signed-off-by: Jay Flowers <jay@unboundforce.com> Assisted-by: claude-opus-4-6
- Add Mcp-Session-Id request header validation in mock handler to verify clients send the correct session ID (TC-009) - Add clarifying comment on proxy.go version constant noting it is the MCP protocol version, not the binary version Addresses PR #71 review feedback from @yvonnedevlinrh. Signed-off-by: Jay Flowers <jay@unboundforce.com> Assisted-by: claude-opus-4-6
Add internal/mcpclient to the THRESHOLDS array with 80% minimum line coverage, matching the spec requirement for new packages. Addresses PR #71 review feedback from @yvonnedevlinrh. Signed-off-by: Jay Flowers <jay@unboundforce.com> Assisted-by: claude-opus-4-6
Address review-council findings: - Wrap marshal/request-creation errors in UnavailableError for consistency - Fix misleading version comment in proxy.go (client version, not protocol) - Remove unused initResponse/toolResponse mock fields Addresses PR #71 review feedback. Signed-off-by: Jay Flowers <jay@unbound.force> Assisted-by: claude-opus-4-6
Summary
Fixes #19.
memory.Client.Call()was sending barehttp.Post()with plain JSON-RPC to Dewey's MCP endpoint, causing HTTP 400 on every call. All memory proxy tools (hivemind_store,hivemind_find,dewey_health) failed withDEWEY_UNAVAILABLE.This PR extracts a shared
internal/mcpclient/package that correctly speaks MCP Streamable HTTP:initializehandshake with protocol version and client identityMcp-Session-Idcapture and reusetools/callenvelope wrappingsync.Mutex+atomic.Int64Both
memory.Clientanddoctor.deweyHealthProbe()now delegate tomcpclient.Client, eliminating ~70 lines of duplicated inline MCP code from the doctor.How to Test
Key test scenarios (22 tests in mcpclient, 16 in memory, 8 in doctor):
TestCall_RejectsBareMethods)How to Demo
replicator serveand invokehivemind_storeorhivemind_findvia MCPDEWEY_UNAVAILABLEerrorsreplicator doctorand observe the Dewey health check passesKey Files Changed
internal/mcpclient/client.gointernal/mcpclient/client_test.gointernal/memory/proxy.goCall()to delegate tomcpclient.Clientinternal/memory/proxy_test.gointernal/doctor/checks.godeweyHealthProbe()from ~70 to 7 linesinternal/doctor/checks_test.goAGENTS.md/README.mdmcpclient/to project structureopenspec/changes/fix-memory-client-mcp-transport/Known Issues
The following findings from the review council were acknowledged but not resolved:
data:without space branch in SSE parsing not directly tested (covered by production code path)context.Contextsupport (documented non-goal for this change)filepath.Joinissue in doctor (not introduced by this change)This PR was generated by /uf.finale (AI-assisted).