Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ jobs:
["internal/gitutil"]=80
["internal/ui"]=75
["internal/mcp"]=70
["internal/mcpclient"]=80
)

for PKG in "${!THRESHOLDS[@]}"; do
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
tag: mcp-transport
author: jay-flowers
category: pattern
created_at: 2026-08-11T18:06:34Z
identity: mcp-transport-20260811T180634-jay-flowers
tier: draft
---

MCP Streamable HTTP Transport Pattern: When speaking MCP Streamable HTTP to a server like Dewey, the client must: (1) send an `initialize` handshake with `protocolVersion: "2025-03-26"`, `clientInfo.name/version`, and `capabilities: {}` before any `tools/call` invocations, (2) set `Accept: application/json, text/event-stream` and `Content-Type: application/json` headers on all requests, (3) capture the `Mcp-Session-Id` response header and attach it to subsequent requests, (4) wrap tool method names in a `tools/call` JSON-RPC envelope with `params.name` and `params.arguments`, (5) parse responses in dual format — check `Content-Type` for `application/json` (direct unmarshal) vs `text/event-stream` (scan for `data:` prefixed lines), (6) handle session recovery by resetting and re-initializing on HTTP 400/404. The `initialized` notification is skipped (Dewey doesn't enforce it). Bare `http.Post()` with plain JSON-RPC will get HTTP 400 from MCP endpoints.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
tag: mcpclient-architecture
author: jay-flowers
category: pattern
created_at: 2026-08-11T18:06:43Z
identity: mcpclient-architecture-20260811T180643-jay-flowers
tier: draft
---

Shared MCP Client Architecture (replicator): The `internal/mcpclient/` package provides a reusable MCP Streamable HTTP client shared by both `memory.Client` (proxy to Dewey tools) and `doctor.deweyHealthProbe()`. Key design decisions: (1) `mcpclient.Config` struct with `Name`, `Version`, `Timeout`, `Logger` for dependency injection, (2) lazy session initialization on first `Call()` rather than at construction time (avoids blocking `NewClient()` if Dewey is down), (3) `sync.Mutex` protects session state (`sessionID`, `inited`) for concurrent goroutine safety, (4) `sync/atomic.Int64` for monotonically increasing JSON-RPC request IDs, (5) `io.LimitReader` at 10MB bounds response body reads, (6) `memory.UnavailableError` preserved via `wrapError()` bridge for backward compatibility with existing callers that use `errors.As`. The doctor went from ~70 lines of inline MCP code to 7 lines.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
tag: review-council
author: jay-flowers
category: pattern
created_at: 2026-08-11T18:06:52Z
identity: review-council-20260811T180652-jay-flowers
tier: draft
---

Review Council Spec Review Patterns: When running the review council on spec artifacts, the most consistently flagged issues across all reviewers were: (1) Thread safety for shared mutable state — 4/5 reviewers flagged missing concurrency safety for session state shared across goroutines (CRITICAL), (2) SSE parsing edge cases — 3/5 reviewers flagged missing scenarios for empty body, malformed JSON, no data line (HIGH), (3) Package naming collisions — architect flagged `mcphttp` vs existing `mcp` package confusion, renamed to `mcpclient` (CRITICAL), (4) Timeout budget for multi-round-trip operations — 3/5 reviewers flagged lazy init adding latency (HIGH), (5) Missing coverage strategy — testing reviewer flagged constitution violation (CRITICAL). Lesson: spec review council catches real implementation bugs before code is written. The thread safety finding alone would have caused CI `-race` failures.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ internal/
org/ Cell domain logic (CRUD, epics, sessions, sync)
comms/ Agent messaging + file reservations
forge/ Orchestration (decompose, spawn, worktree, review, insights)
mcpclient/ MCP Streamable HTTP client (shared transport)
memory/ Dewey proxy + deprecated tool stubs
gitutil/ Git worktree operations (os/exec)
doctor/ Health check engine
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ internal/
org/ Cell CRUD, epics, sessions, sync
comms/ Agent messaging, file reservations
forge/ Decomposition, spawning, worktrees, review, insights
mcpclient/ MCP Streamable HTTP client (shared transport)
memory/ Dewey proxy, deprecated tool stubs
gitutil/ Git worktree operations (os/exec)
doctor/ Health check engine
Expand Down
85 changes: 10 additions & 75 deletions internal/doctor/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,15 @@
package doctor

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"time"

"github.com/unbound-force/replicator/internal/config"
"github.com/unbound-force/replicator/internal/db"
"github.com/unbound-force/replicator/internal/mcpclient"
)

// CheckResult holds the outcome of a single health check.
Expand Down Expand Up @@ -119,78 +116,16 @@ func checkDewey(deweyURL string) CheckResult {
}
}

// deweyHealthProbe sends an MCP initialize request to verify Dewey is alive.
// This is a lightweight probe that does not establish a full session.
// deweyHealthProbe uses the shared MCP client to verify Dewey is alive.
// It sends an initialize handshake followed by a dewey_health tools/call.
func deweyHealthProbe(deweyURL string) error {
reqBody := map[string]any{
"jsonrpc": "2.0",
"method": "initialize",
"id": 1,
"params": map[string]any{
"protocolVersion": "2025-03-26",
"capabilities": map[string]any{},
"clientInfo": map[string]any{
"name": "replicator-doctor",
"version": "1.0.0",
},
},
}

body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}

req, err := http.NewRequest(http.MethodPost, deweyURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")

client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}

// Read the SSE response — look for a JSON-RPC result in the event stream.
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}

// The response is SSE format: "event: message\ndata: {json}\n\n"
// Extract the JSON data line.
for _, line := range strings.Split(string(respBody), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
var rpcResp struct {
Result any `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(data), &rpcResp); err != nil {
return fmt.Errorf("parse response: %w", err)
}
if rpcResp.Error != nil {
return fmt.Errorf("dewey error: %s", rpcResp.Error.Message)
}
// Got a successful initialize response — Dewey is alive.
return nil
}

return fmt.Errorf("no valid response from Dewey")
client := mcpclient.New(deweyURL, mcpclient.Config{
Name: "replicator-doctor",
Version: "1.0.0",
Timeout: 5 * time.Second,
})
_, err := client.Call("dewey_health", map[string]any{})
return err
}

// checkConfigDir verifies the config directory exists.
Expand Down
68 changes: 57 additions & 11 deletions internal/doctor/checks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import (

// mcpHandler returns an http.HandlerFunc that mimics the MCP Streamable HTTP
// transport. It validates POST method, Content-Type, Accept header, and
// JSON-RPC protocol fields. Responds with SSE-formatted JSON-RPC success.
// JSON-RPC protocol fields. Handles both "initialize" and "tools/call" methods
// with proper MCP response formats.
func mcpHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Expand Down Expand Up @@ -50,21 +51,47 @@ func mcpHandler() http.HandlerFunc {
http.Error(w, "invalid jsonrpc version", http.StatusBadRequest)
return
}
if req["method"] == nil {

method, _ := req["method"].(string)
if method == "" {
http.Error(w, "missing method", http.StatusBadRequest)
return
}

id, _ := req["id"].(float64)
resp := map[string]any{
"jsonrpc": "2.0",
"result": map[string]any{
"capabilities": map[string]any{},
"protocolVersion": "2025-03-26",
"serverInfo": map[string]any{"name": "dewey", "version": "test"},
},
"id": int(id),

var resp map[string]any

switch method {
case "initialize":
resp = map[string]any{
"jsonrpc": "2.0",
"result": map[string]any{
"capabilities": map[string]any{},
"protocolVersion": "2025-03-26",
"serverInfo": map[string]any{"name": "dewey", "version": "test"},
},
"id": int(id),
}
// Set session ID header for MCP session management.
w.Header().Set("Mcp-Session-Id", "test-session-id")
case "tools/call":
// Return a successful MCP tools/call response with content wrapper.
toolResult := `{"status":"ok"}`
resp = map[string]any{
"jsonrpc": "2.0",
"result": map[string]any{
"content": []map[string]any{
{"type": "text", "text": toolResult},
},
},
"id": int(id),
}
default:
http.Error(w, "unknown method", http.StatusBadRequest)
return
}

respJSON, err := json.Marshal(resp)
if err != nil {
http.Error(w, "encode error", http.StatusInternalServerError)
Expand Down Expand Up @@ -206,9 +233,28 @@ func TestCheckDewey_HTTPError(t *testing.T) {
}

func TestCheckDewey_RPCError(t *testing.T) {
// Return success for initialize but a JSON-RPC error for tools/call.
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]any
json.Unmarshal(body, &req)

method, _ := req["method"].(string)
id, _ := req["id"].(float64)
callCount++

w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "event: message\ndata: {\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32601,\"message\":\"method not found\"},\"id\":1}\n\n")

if method == "initialize" {
resp := fmt.Sprintf(`{"jsonrpc":"2.0","result":{"capabilities":{},"protocolVersion":"2025-03-26","serverInfo":{"name":"dewey","version":"test"}},"id":%d}`, int(id))
w.Header().Set("Mcp-Session-Id", "test-session")
fmt.Fprintf(w, "event: message\ndata: %s\n\n", resp)
return
}

// Return error for tools/call.
fmt.Fprintf(w, "event: message\ndata: {\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32601,\"message\":\"method not found\"},\"id\":%d}\n\n", int(id))
}))
defer srv.Close()

Expand Down
Loading