diff --git a/internal/mcp/server.go b/internal/mcp/server.go index b57323e..e098aae 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -561,6 +561,131 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { return textResult(out), nil }) + // ─── Client introspection tools (plan: client-lifecycle Phase 3) ── + + register(&mcp.Tool{Name: "whoami", Description: "Get the current entity record, peers, and health for a host", + InputSchema: objSchema(prop{"hostname", "string", "Hostname of the calling machine"}), + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + hostname, _ := args["hostname"].(string) + if hostname == "" { + return textResult("error: hostname required"), nil + } + slug := "ws:" + hostname + return queryRows(ctx, pool, ` + SELECT e.slug, e.type, e.name, e.state, + COALESCE(st.health, 'unknown') AS health, + COALESCE(st.last_check_at::text, '') AS last_check, + e.attributes->>'mesh_ip' AS mesh_ip, + e.attributes->>'age_pubkey' AS age_pubkey, + e.enrolled_at + FROM entities e + LEFT JOIN entity_status st ON st.entity_id = e.id + WHERE e.slug = $1 + ORDER BY e.slug`, slug), nil + }) + + register(&mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk", + InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug (e.g. service:jellyfin, lxc:caddy)"}), + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["service_slug"].(string) + if slug == "" { + return textResult("error: service_slug required"), nil + } + return queryRows(ctx, pool, ` + SELECT e.slug, e.type, e.name, e.state, + COALESCE(st.health, 'unknown') AS health, + COALESCE(st.last_check_at::text, '') AS last_check, + e.version, e.updated_at, + COALESCE(e.attributes::text, '{}') AS attrs + FROM entities e + LEFT JOIN entity_status st ON st.entity_id = e.id + WHERE e.slug = $1`, slug), nil + }) + + register(&mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service", + InputSchema: objSchema( + prop{"service_slug", "string", "Entity slug"}, + prop{"action", "string", "Planned action (restart, deploy, destroy, etc.)"}), + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["service_slug"].(string) + action, _ := args["action"].(string) + if slug == "" || action == "" { + return textResult("error: service_slug and action required"), nil + } + return queryRows(ctx, pool, ` + SELECT e.slug, e.type, e.state, + CASE + WHEN $2 IN ('restart', 'logs', 'status') THEN 'reversible_low' + WHEN $2 IN ('deploy', 'upgrade', 'configure') THEN 'config_mutation' + WHEN $2 IN ('destroy', 'wipe', 'revoke') THEN 'destructive' + ELSE 'read_only' + END AS risk_class, + CASE + WHEN $2 IN ('read_only','reversible_low') THEN 'auto-act' + WHEN $2 = 'config_mutation' THEN 'operator-approval' + ELSE 'operator-approval+confirmation' + END AS approval + FROM entities e WHERE e.slug = $1`, slug, action), nil + }) + + register(&mcp.Tool{Name: "get_change_history", Description: "Last N change-ledger entries for an entity", + InputSchema: objSchema( + prop{"entity_slug", "string", "Entity slug"}, + prop{"limit", "integer", "Max entries (default 20)"}), + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["entity_slug"].(string) + limit := int(getFloat(args, "limit", 20)) + return queryRows(ctx, pool, ` + SELECT al.timestamp, al.actor_type, al.actor_label, + al.action, al.method, al.path, + al.details::text AS details + FROM audit_log al + JOIN entities e ON e.id = al.entity_id + WHERE e.slug = $1 + ORDER BY al.timestamp DESC + LIMIT $2`, slug, limit), nil + }) + + register(&mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count", + InputSchema: objSchema(), + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return queryRows(ctx, pool, ` + SELECT e.slug, e.type, e.state, + COALESCE(st.health, 'unknown') AS health, + COALESCE(st.last_check_at::text, '') AS last_check, + COALESCE(st.disk_usage_pct, 0) AS disk_pct, + COALESCE(st.drift_count, 0) AS drift_count + FROM entities e + LEFT JOIN entity_status st ON st.entity_id = e.id + ORDER BY st.health, e.slug + LIMIT 200 + `), nil + }) + + register(&mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key", + InputSchema: objSchema(prop{"caller_pubkey", "string", "Age public key of the caller (optional)"}), + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + pubkey, _ := args["caller_pubkey"].(string) + // Match entities where age_pubkey attribute contains the caller's key. + query := ` + SELECT e.slug, e.type, e.name, + e.attributes->>'age_pubkey' AS age_pubkey + FROM entities e + WHERE e.attributes->>'age_pubkey' IS NOT NULL` + var dbArgs []any + if pubkey != "" { + query += ` AND e.attributes->>'age_pubkey' = $1` + dbArgs = append(dbArgs, pubkey) + } + query += ` ORDER BY e.slug LIMIT 100` + return queryRows(ctx, pool, query, dbArgs...), nil + }) + return s }