// Package mcp implements the Oikos MCP interface (plan R3-10). // Uses the official MCP Go SDK with Streamable HTTP transport. package mcp import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "time" "github.com/dtoro/oikos/internal/db" "github.com/google/jsonschema-go/jsonschema" "github.com/google/uuid" "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. // agentID is the Hermes agent entity UUID; tool calls are logged to agent_activity. func NewHandler(pool *db.Pool, token string, agentID uuid.UUID) http.Handler { s := newServer(pool, agentID) handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { if token != "" { if r.Header.Get("Authorization") != "Bearer "+token { return nil } } return s }, nil) return handler } // toolHandler is the function signature registered via AddTool. type toolHandler = mcp.ToolHandler func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{ Logger: slog.Default(), }) register := func(tool *mcp.Tool, handler toolHandler) { s.AddTool(tool, withActivityLogging(pool, agentID, tool.Name, handler)) } register(&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 }) register(&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, ` SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at FROM entities e WHERE ($1::text IS NULL OR e.type = $1) AND ($2::text IS NULL OR e.state = $2) AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%') ORDER BY e.slug LIMIT $4`, nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), nil }) register(&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, ` SELECT r.type, src.slug AS source, tgt.slug AS target FROM relationships r JOIN entities src ON src.id = r.source_id JOIN entities tgt ON tgt.id = r.target_id WHERE (src.slug = $1 OR tgt.slug = $1) AND r.valid_to IS NULL ORDER BY r.type`, slug), nil }) register(&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)) return queryRows(ctx, pool, "SELECT slug, CAST(depth AS int) FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2)", slug, depth), nil }) register(&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 }) register(&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 FROM audit_log WHERE ($1::text IS NULL OR entity_id::text = $1) ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil }) register(&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 := nStr(args["query"]) return queryRows(ctx, pool, ` SELECT id, title, LEFT(content, 500) AS preview FROM knowledge_entities WHERE ($1::text IS NULL OR title ILIKE '%'||$1||'%' OR content ILIKE '%'||$1||'%') ORDER BY title LIMIT 20`, q), nil }) register(&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, ` SELECT time_bucket('1 hour', ts) AS bucket, entity_id::text, metric, ROUND(avg(value)::numeric, 2) AS avg, ROUND(min(value)::numeric, 2) AS min, ROUND(max(value)::numeric, 2) AS max FROM metric_samples WHERE ts > now() - make_interval(hours => $1) GROUP BY bucket, entity_id, metric ORDER BY bucket DESC LIMIT 100`, hours), nil }) // ─── Phase 4: new tools ────────────────────────────────────────── register(&mcp.Tool{Name: "get_signal_history", Description: "Query open and recent signals", InputSchema: objSchema( prop{"entity_slug", "string", "Filter by target entity slug"}, prop{"state", "string", "Filter by signal state (raised, resolved)"}, 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, ` SELECT s.entity_id::text, s.kind, s.severity, s.state, s.occurrence_count, e.slug AS target_slug, s.first_seen_at, s.last_seen_at FROM signals s LEFT JOIN entities e ON e.id = s.target_entity_id WHERE ($1::text IS NULL OR e.slug = $1) AND ($2::text IS NULL OR s.state = $2) ORDER BY s.last_seen_at DESC LIMIT $3`, nStr(args["entity_slug"]), nStr(args["state"]), limit), nil }) register(&mcp.Tool{Name: "get_patterns", Description: "List learned action patterns", InputSchema: objSchema( prop{"status", "string", "Filter by status (hypothesized, validated, active)"}, prop{"entity_type", "string", "Filter by applies_type"}, prop{"action", "string", "Filter by action"}), }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) return queryRows(ctx, pool, ` SELECT p.entity_id::text, p.applies_type, p.action, p.pattern, p.confidence, p.evidence_count, p.success_count, p.failure_count, p.status, p.quarantined, p.version, p.last_validated_at FROM patterns p WHERE ($1::text IS NULL OR p.status = $1) AND ($2::text IS NULL OR p.applies_type = $2) AND ($3::text IS NULL OR p.action = $3) ORDER BY p.applies_type, p.action`, nStr(args["status"]), nStr(args["entity_type"]), nStr(args["action"])), nil }) register(&mcp.Tool{Name: "get_skills", Description: "List available automation skills", InputSchema: objSchema( prop{"status", "string", "Filter by status (active, inactive, deprecated)"}, ), }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) return queryRows(ctx, pool, ` SELECT s.entity_id::text, s.version, s.name, LEFT(s.procedure::text, 300) AS procedure_preview, s.applies_type, s.action, s.status, s.success_rate, s.changed_by::text, s.change_reason, s.last_used_at FROM skills s WHERE ($1::text IS NULL OR s.status = $1) ORDER BY s.name, s.version DESC`, nStr(args["status"])), nil }) register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (Hermes-only mutation path)", InputSchema: objSchema( prop{"target", "string", "Target entity slug"}, prop{"action", "string", "Action to perform"}, ), }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) targetSlug, _ := args["target"].(string) action, _ := args["action"].(string) if targetSlug == "" || action == "" { return textResult("error: target and action are required"), nil } var targetID uuid.UUID if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil { return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil } id, err := uuid.NewV7() if err != nil { return textResult(fmt.Sprintf("uuid error: %v", err)), nil } correlationID := uuid.New().String() execName := action + " on " + targetSlug + " (" + id.String()[:8] + ")" execSlug := "exec:" + targetSlug + ":" + id.String()[:8] _, err = pool.Exec(ctx, ` INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'execution', $3, '{}')`, id, execSlug, execName) if err != nil { return textResult(fmt.Sprintf("insert entity: %v", err)), nil } _, err = pool.Exec(ctx, ` INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'unclassified', 'pending', $4, $5)`, id, targetID, action, correlationID, agentID) if err != nil { return textResult(fmt.Sprintf("insert execution: %v", err)), nil } return textResult(fmt.Sprintf("execution requested: id=%s target=%s action=%s correlation=%s", id, targetSlug, action, correlationID)), nil }) register(&mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity", InputSchema: objSchema( prop{"entity_id", "string", "Entity slug"}, prop{"days", "integer", "Look-back window in days (default 7)"}), }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["entity_id"].(string) days := int(getFloat(args, "days", 7)) return queryRows(ctx, pool, ` SELECT metric, ROUND(avg(value)::numeric, 2) AS avg_val, ROUND(stddev(value)::numeric, 2) AS std_val, count(*) AS sample_count, ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope FROM metric_samples ms JOIN entities e ON e.id = ms.entity_id WHERE e.slug = $1 AND ts >= now() - make_interval(days => $2) GROUP BY metric ORDER BY metric`, slug, days), nil }) register(&mcp.Tool{Name: "get_event_timeline", Description: "Get recent events", InputSchema: objSchema( prop{"severity", "string", "Filter by severity (info, warn, error)"}, prop{"entity_slug", "string", "Filter by entity slug"}, 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, ` SELECT ev.ts, ev.event_type, ev.severity, ev.actor, e.slug AS entity_slug, ev.message, ev.correlation_id FROM events ev LEFT JOIN entities e ON e.id = ev.entity_id WHERE ($1::text IS NULL OR ev.severity = $1) AND ($2::text IS NULL OR e.slug = $2) ORDER BY ev.ts DESC LIMIT $3`, nStr(args["severity"]), nStr(args["entity_slug"]), limit), nil }) register(&mcp.Tool{Name: "get_agent_activity", Description: "Agent self-inspection: query agent activity log", InputSchema: objSchema( 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, ` SELECT id, ts, agent_id::text, session_id, activity_type, tool_name, entity_id::text, left(input_summary, 200) AS input_summary, left(output_summary, 200) AS output_summary, duration_ms, token_count, success, correlation_id FROM agent_activity WHERE agent_id = $1 ORDER BY ts DESC LIMIT $2`, agentID, limit), nil }) return s } // withActivityLogging wraps a tool handler to record agent_activity rows. func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next mcp.ToolHandler) mcp.ToolHandler { if agentID == uuid.Nil { return next } return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { start := time.Now() result, err := next(ctx, req) duration := int(time.Since(start).Milliseconds()) // Build input summary (first 500 chars of args) inputSummary := "" if req != nil && len(req.Params.Arguments) > 0 { inputSummary = string(req.Params.Arguments) } if len(inputSummary) > 500 { inputSummary = inputSummary[:500] } // Build output summary outputSummary := "" success := err == nil if result != nil { for _, c := range result.Content { if tc, ok := c.(*mcp.TextContent); ok { outputSummary = tc.Text break } } } if err != nil { outputSummary = err.Error() success = false } if len(outputSummary) > 500 { outputSummary = outputSummary[:500] } correlationID := uuid.New().String() _, logErr := pool.Exec(ctx, ` INSERT INTO agent_activity (agent_id, activity_type, tool_name, input_summary, output_summary, duration_ms, success, correlation_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, agentID, "tool_call", toolName, inputSummary, outputSummary, duration, success, correlationID) if logErr != nil { slog.Warn("mcp: log agent_activity", "error", logErr) } return result, err } } // ─── Helpers ────────────────────────────────────────────────────────── func argsMap(req *mcp.CallToolRequest) map[string]any { if req == nil || len(req.Params.Arguments) == 0 { return nil } var m map[string]any json.Unmarshal(req.Params.Arguments, &m) return m } func getFloat(m map[string]any, key string, def float64) float64 { if m == nil { return def } switch v := m[key].(type) { case float64: return v case int: return float64(v) case json.Number: f, err := v.Float64() if err != nil { return def } return f } return def } func nStr(v any) any { if v == nil { return nil } s, _ := v.(string) if s == "" { return nil } return s } func textResult(s string) *mcp.CallToolResult { return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: s}}, } } func queryEntity(ctx context.Context, pool *db.Pool, idOrSlug string) *mcp.CallToolResult { var id uuid.UUID if u, err := uuid.Parse(idOrSlug); err == nil { id = u } else { pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&id) } if id == uuid.Nil { return textResult(fmt.Sprintf("entity not found: %s", idOrSlug)) } return queryRows(ctx, pool, ` SELECT slug, type, name, state, attributes, maintenance_until::text, version, created_at, updated_at FROM entities WHERE id = $1`, id) } func queryRows(ctx context.Context, pool *db.Pool, query string, args ...any) *mcp.CallToolResult { rows, err := pool.Query(ctx, query, args...) if err != nil { return textResult(fmt.Sprintf("error: %v", err)) } defer rows.Close() cols := rows.FieldDescriptions() var items []map[string]any for rows.Next() { vals, err := rows.Values() if err != nil { continue } m := make(map[string]any) for i, col := range cols { m[string(col.Name)] = fmt.Sprintf("%v", vals[i]) } items = append(items, m) } if err := rows.Err(); err != nil { return textResult(fmt.Sprintf("error: %v", err)) } data, _ := json.MarshalIndent(items, "", " ") return textResult(string(data)) }