phase 2 review: fix SSE deadlock, MCP panic, lifecycle 500, NOT NULL bug

Reviewed the phase-2 implementation (parts 2–5) end to end. The suite hung
for 600s and several handlers were never exercised because there were no
tests for the new mutation/event/MCP surface. Fixes:

- CRITICAL: sseListener ran on context.Background() and held a pooled
  connection forever, so pool.Close() deadlocked (600s test timeout).
  NewHandler now takes a ctx that governs the listener; ListenAndServe and
  the test helper cancel it before closing the pool.
- CRITICAL: MCP AddTool panicked ("missing input schema") at construction
  under go-sdk v1.6.1 — so NewHandler (and every API handler) panicked.
  Added object input schemas to all 8 tools via an objSchema helper.
- HIGH: PatchEntity parsed lifecycle transitions as map[string][]string but
  the shape is {from:{to:{requires:[]}}}, so every state-change PATCH 500'd.
  Parse the nested shape; allow same-state no-ops.
- HIGH: CreateEntity bound SQL NULL for attributes when omitted, violating
  the NOT NULL column (the default only applies when omitted). Default to
  '{}'.
- MED: serveSSEWriter ignored the request ctx (per-client goroutine leak on
  disconnect) and set an invalid Content-Length: -1. Thread ctx through;
  omit the header. writeSSE now nil-checks the flusher (io.Pipe path passed
  nil → would have panicked on first event).
- MED: SSE `data:` leaked raw sqlcgen.Event (PascalCase, base64 JSONB).
  Emit canonical gen.Event so SSE matches GET /events. Verified live.
- LOW: CreateEntity uses uuid.NewV7 (ADR-0005) + real actor from context in
  audit; removed dead bearerAuth; fixed vet unkeyed-field warnings.

Tests (would have caught all of the above): entity create/patch with
If-Match 409/400, valid+invalid lifecycle transitions, idempotency replay,
duplicate-slug 409, abstract-type 422, event+audit side effects, MCP tool
registration. Live smoke test confirmed NOTIFY→listener→SSE delivery.

Also adds the missing Phase 2 deliverable: Gitea Actions CI (vet,
golangci-lint, govulncheck, generated-code drift guard, race tests against
TimescaleDB, docker build) and wires sqlc into `make generate`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 13:26:43 +02:00
parent 6b61495e3b
commit f1b0b65149
10 changed files with 457 additions and 202 deletions

View File

@@ -11,11 +11,28 @@ import (
"time"
"github.com/dtoro/oikos/internal/db"
"github.com/google/jsonschema-go/jsonschema"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// prop is one input-schema property (name → type + description).
type prop struct {
name, typ, desc string
}
// objSchema builds an "object" JSON Schema from a list of properties. The
// MCP SDK requires every tool to declare an object input schema so tools
// are self-describing to the agent; a nil schema panics at registration.
func objSchema(props ...prop) *jsonschema.Schema {
s := &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{}}
for _, p := range props {
s.Properties[p.name] = &jsonschema.Schema{Type: p.typ, Description: p.desc}
}
return s
}
// NewHandler creates an http.Handler that serves the Oikos MCP server.
func NewHandler(pool *db.Pool, token string) http.Handler {
s := newServer(pool)
@@ -38,13 +55,21 @@ func newServer(pool *db.Pool) *mcp.Server {
// All tools use the untyped handler (s.AddTool) for simplicity.
// Arguments are accessed via req.Parameters.Arguments.(map[string]any).
s.AddTool(&mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
s.AddTool(&mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID",
InputSchema: objSchema(prop{"slug_or_id", "string", "Entity slug (e.g. host:hubris) or UUID"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
idOrSlug, _ := args["slug_or_id"].(string)
return queryEntity(ctx, pool, idOrSlug), nil
})
s.AddTool(&mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
s.AddTool(&mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search",
InputSchema: objSchema(
prop{"type", "string", "Filter by entity type"},
prop{"state", "string", "Filter by lifecycle state"},
prop{"q", "string", "Substring match on slug or name"},
prop{"limit", "integer", "Max rows (default 50)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
limit := int(getFloat(args, "limit", 50))
return queryRows(ctx, pool, `
@@ -57,7 +82,9 @@ func newServer(pool *db.Pool) *mcp.Server {
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), nil
})
s.AddTool(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
s.AddTool(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
InputSchema: objSchema(prop{"entity_id", "string", "Entity slug"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["entity_id"].(string)
return queryRows(ctx, pool, `
@@ -69,7 +96,11 @@ func newServer(pool *db.Pool) *mcp.Server {
ORDER BY r.type`, slug), nil
})
s.AddTool(&mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
s.AddTool(&mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down",
InputSchema: objSchema(
prop{"entity_id", "string", "Entity slug"},
prop{"depth", "integer", "Traversal depth (default 3)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["entity_id"].(string)
depth := int(getFloat(args, "depth", 3))
@@ -78,14 +109,18 @@ func newServer(pool *db.Pool) *mcp.Server {
slug, depth), nil
})
s.AddTool(&mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
s.AddTool(&mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary",
InputSchema: objSchema(),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return queryRows(ctx, pool, `
SELECT e.slug, e.type, st.health, st.last_check_at
FROM entity_status st JOIN entities e ON e.id = st.entity_id
ORDER BY e.slug`), nil
})
s.AddTool(&mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
s.AddTool(&mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log",
InputSchema: objSchema(prop{"entity_id", "string", "Filter by affected entity UUID"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
return queryRows(ctx, pool, `
SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id
@@ -94,7 +129,9 @@ func newServer(pool *db.Pool) *mcp.Server {
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
})
s.AddTool(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
s.AddTool(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation",
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
q, _ := args["query"].(string)
return queryRows(ctx, pool, `
@@ -104,7 +141,9 @@ func newServer(pool *db.Pool) *mcp.Server {
ORDER BY title LIMIT 20`, q), nil
})
s.AddTool(&mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
s.AddTool(&mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
hours := int(getFloat(args, "hours", 24))
return queryRows(ctx, pool, `