Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
tag: doctor-dewey-health-check
author: anonymous
category: gotcha
created_at: 2026-06-10T17:08:54Z
identity: doctor-dewey-health-check-20260610T170854-anonymous
tier: draft
---

When writing doctor health checks that probe MCP endpoints, always use the JSON-RPC 2.0 POST method via the existing memory.Client rather than raw HTTP GET. The MCP Streamable HTTP transport only accepts POST for JSON-RPC calls; a plain GET without SSE headers returns HTTP 400 or 405. The replicator project already has memory.NewClient(deweyURL).Health() which sends a proper JSON-RPC POST to dewey_health. Reusing this avoids protocol mismatch bugs and keeps Dewey connectivity logic in one place (DRY). This was the root cause of GitHub issue #16 where checkDewey() in internal/doctor/checks.go was sending GET against a POST-only endpoint.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
tag: spec-design-alignment
author: anonymous
category: pattern
created_at: 2026-06-10T17:09:04Z
identity: spec-design-alignment-20260610T170904-anonymous
tier: draft
---

During the fix-dewey-doctor-check change, the design document specified using errors.As to distinguish memory.UnavailableError from generic errors, but the implementation correctly simplified this since both cases produce the same "warn" status. Multiple spec reviewers (adversary, architect, guard, testing) independently flagged this design-vs-implementation inconsistency as a LOW finding. Lesson: when a design decision is simplified during implementation, update the design artifact immediately to prevent confusion. The design doc is a living document that should reflect what was actually built, not just what was planned.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
tag: testing-json-rpc-mocks
author: anonymous
category: pattern
created_at: 2026-06-10T17:09:00Z
identity: testing-json-rpc-mocks-20260610T170900-anonymous
tier: draft
---

When writing test mocks for JSON-RPC endpoints in the replicator project, the mock handler should validate protocol conformance — not just return a canned response. Specifically: (1) validate HTTP method is POST, (2) validate Content-Type is application/json, (3) validate the jsonrpc field is "2.0", and (4) validate a method field is present. This provides regression protection against the exact class of bug being tested (protocol mismatch). The divisor-testing reviewer flagged shallow mocks as a HIGH finding during code review, requiring a second iteration to add these assertions. Always match assertion depth across related test functions — if TestCheckGit asserts Name, Status, Message, and Duration, then TestCheckDewey should assert the same fields.
96 changes: 82 additions & 14 deletions internal/doctor/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
package doctor

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
Expand Down Expand Up @@ -89,13 +92,14 @@ func checkDatabase(store *db.Store) CheckResult {
}

// checkDewey verifies the Dewey semantic search endpoint is reachable.
// Uses a simple HTTP GET -- a non-200 response is a warning, not a failure,
// because Dewey is optional for core operations.
// Sends an MCP initialize request (JSON-RPC 2.0 POST) to verify connectivity.
// The MCP Streamable HTTP transport requires POST with Accept header including
// both application/json and text/event-stream. A failure is a warning, not an
// error, because Dewey is optional for core operations.
func checkDewey(deweyURL string) CheckResult {
start := time.Now()

client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(deweyURL)
err := deweyHealthProbe(deweyURL)
elapsed := time.Since(start)

if err != nil {
Expand All @@ -106,16 +110,6 @@ func checkDewey(deweyURL string) CheckResult {
Duration: elapsed,
}
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return CheckResult{
Name: "dewey",
Status: "warn",
Message: fmt.Sprintf("Dewey returned HTTP %d at %s", resp.StatusCode, deweyURL),
Duration: elapsed,
}
}

return CheckResult{
Name: "dewey",
Expand All @@ -125,6 +119,80 @@ 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.
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")
}

// checkConfigDir verifies the config directory exists.
func checkConfigDir() CheckResult {
start := time.Now()
Expand Down
127 changes: 114 additions & 13 deletions internal/doctor/checks_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,82 @@
package doctor

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

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

// 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.
func mcpHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}

if ct := r.Header.Get("Content-Type"); ct != "application/json" {
http.Error(w, "wrong content type", http.StatusUnsupportedMediaType)
return
}

accept := r.Header.Get("Accept")
if !strings.Contains(accept, "application/json") || !strings.Contains(accept, "text/event-stream") {
http.Error(w, "Accept must contain both 'application/json' and 'text/event-stream'", http.StatusBadRequest)
return
}

body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}

var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}

if req["jsonrpc"] != "2.0" {
http.Error(w, "invalid jsonrpc version", http.StatusBadRequest)
return
}
if req["method"] == nil {
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),
}
respJSON, err := json.Marshal(resp)
if err != nil {
http.Error(w, "encode error", http.StatusInternalServerError)
return
}

// Respond in SSE format, matching the MCP Streamable HTTP transport.
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprintf(w, "event: message\ndata: %s\n\n", respJSON)
}
}

func testStore(t *testing.T) *db.Store {
t.Helper()
store, err := db.OpenMemory()
Expand All @@ -22,11 +90,8 @@ func testStore(t *testing.T) *db.Store {
func TestRun_AllChecks(t *testing.T) {
store := testStore(t)

// Mock Dewey as healthy.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "healthy"}`))
}))
// Mock Dewey as healthy JSON-RPC endpoint.
srv := httptest.NewServer(mcpHandler())
defer srv.Close()

cfg := &config.Config{
Expand Down Expand Up @@ -91,34 +156,72 @@ func TestCheckDatabase_Closed(t *testing.T) {
}

func TestCheckDewey_Healthy(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
srv := httptest.NewServer(mcpHandler())
defer srv.Close()

result := checkDewey(srv.URL)
if result.Name != "dewey" {
t.Errorf("name = %q, want %q", result.Name, "dewey")
}
if result.Status != "pass" {
t.Errorf("status = %q, want %q", result.Status, "pass")
t.Errorf("status = %q, want %q (message: %s)", result.Status, "pass", result.Message)
}
if !strings.Contains(result.Message, "Dewey is reachable") {
t.Errorf("message = %q, want it to contain %q", result.Message, "Dewey is reachable")
}
if result.Duration <= 0 {
t.Error("duration should be positive")
}
}

func TestCheckDewey_Unreachable(t *testing.T) {
result := checkDewey("http://127.0.0.1:1")
if result.Name != "dewey" {
t.Errorf("name = %q, want %q", result.Name, "dewey")
}
if result.Status != "warn" {
t.Errorf("status = %q, want %q for unreachable Dewey", result.Status, "warn")
}
if !strings.Contains(result.Message, "not reachable") {
t.Errorf("message = %q, want it to contain %q", result.Message, "not reachable")
}
}

func TestCheckDewey_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
http.Error(w, "internal server error", http.StatusInternalServerError)
}))
defer srv.Close()

result := checkDewey(srv.URL)
if result.Name != "dewey" {
t.Errorf("name = %q, want %q", result.Name, "dewey")
}
if result.Status != "warn" {
t.Errorf("status = %q, want %q for HTTP 500", result.Status, "warn")
}
if !strings.Contains(result.Message, "not reachable") {
t.Errorf("message = %q, want it to contain %q", result.Message, "not reachable")
}
}

func TestCheckDewey_RPCError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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")
}))
defer srv.Close()

result := checkDewey(srv.URL)
if result.Name != "dewey" {
t.Errorf("name = %q, want %q", result.Name, "dewey")
}
if result.Status != "warn" {
t.Errorf("status = %q, want %q for JSON-RPC error", result.Status, "warn")
}
if !strings.Contains(result.Message, "not reachable") {
t.Errorf("message = %q, want it to contain %q", result.Message, "not reachable")
}
}

func TestCheckConfigDir(t *testing.T) {
Expand All @@ -138,9 +241,7 @@ func TestCheckConfigDir(t *testing.T) {
func TestCheckResult_StatusValues(t *testing.T) {
// Verify that all results use valid status values.
store := testStore(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
srv := httptest.NewServer(mcpHandler())
defer srv.Close()

cfg := &config.Config{DeweyURL: srv.URL}
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/fix-dewey-doctor-check/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: unbound-force
created: 2026-06-10
Loading
Loading