diff --git a/VERSION b/VERSION index 2ba6141..286d5b0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.0 \ No newline at end of file +0.24.0 \ No newline at end of file diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 49d17dd..60a6bdb 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "strings" "time" @@ -63,18 +64,30 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg { nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil }}, - {tool: &mcp.Tool{Name: "get_relations", Description: "List inbound/outbound edges for one entity", - InputSchema: objSchema(prop{"entity_id", "string", "Entity slug"}), + {tool: &mcp.Tool{Name: "get_relations", Description: "List inbound/outbound edges for one entity, optionally filtered by relationship type", + InputSchema: objSchema( + prop{"entity_id", "string", "Entity slug"}, + prop{"types", "string", "Comma-separated relationship types to include (e.g. 'hosts,provides,depends-on'). Omit for all."}, + ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["entity_id"].(string) - return queryRows(ctx, pool, ` + typesStr, _ := args["types"].(string) + if slug == "" { + return textResult("entity_id is required"), nil + } + query := ` 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 + WHERE (src.slug = $1 OR tgt.slug = $1) AND r.valid_to IS NULL` + if typesStr != "" { + query += ` AND r.type = ANY(string_to_array($2, ','))` + return queryRows(ctx, pool, query, slug, typesStr), nil + } + query += ` ORDER BY r.type` + return queryRows(ctx, pool, query, slug), nil }}, {tool: &mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down", @@ -90,14 +103,23 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg { slug, depth), nil }}, - {tool: &mcp.Tool{Name: "get_health_summary", Description: "Full fleet health per entity — healthy/degraded/down/unknown", - InputSchema: objSchema(), + {tool: &mcp.Tool{Name: "get_health_summary", Description: "Fleet health per entity — optionally filter by health state(s)", + InputSchema: objSchema( + prop{"health", "string", "Comma-separated health states to include (e.g. 'down,stale'). Omit for all."}, + ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return queryRows(ctx, pool, ` + args := argsMap(req) + healthStr, _ := args["health"].(string) + query := ` SELECT e.slug, e.type, st.health, st.last_check_at FROM entity_status st JOIN entities e ON e.id = st.entity_id - WHERE e.type <> 'check' - ORDER BY e.slug`), nil + WHERE e.type <> 'check'` + if healthStr != "" { + query += ` AND st.health = ANY(string_to_array($1, ','))` + return queryRows(ctx, pool, query, healthStr), nil + } + query += ` ORDER BY e.slug` + return queryRows(ctx, pool, query), nil }}, {tool: &mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log", @@ -726,7 +748,7 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg { (e.attributes->>'pve_id')::int`, statePtr), "lxc_list"), nil }}, - {tool: &mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP", + {tool: &mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP — returns scheduler health state plus a live HTTP probe", InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) @@ -735,7 +757,13 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg { return textResult("service_slug is required"), nil } rows, err := pool.Query(ctx, ` - SELECT st.health, st.last_check_at, e.attributes->>'url' AS url + SELECT st.health, st.last_check_at, + COALESCE( + e.attributes->>'url', + CASE WHEN e.attributes->>'public_host' IS NOT NULL + THEN 'https://' || e.attributes->>'public_host' + END + ) AS url FROM entity_status st JOIN entities e ON e.id = st.entity_id WHERE e.slug = $1`, slug) @@ -749,9 +777,17 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg { var health, lastCheck, url string rows.Scan(&health, &lastCheck, &url) if url == "" { - url = "(no URL in entity attributes)" + return textResult(fmt.Sprintf("health=%s last_check=%s url=no-url (entity has no url or public_host attribute)", health, lastCheck)), nil } - return textResult(fmt.Sprintf("health=%s last_check=%s url=%s", health, lastCheck, url)), nil + // Live HTTP probe — HEAD request to check current state + code := "n/a" + if resp, err := http.Head(url); err == nil { + resp.Body.Close() + code = fmt.Sprintf("%d", resp.StatusCode) + } else { + code = fmt.Sprintf("err: %v", err) + } + return textResult(fmt.Sprintf("health=%s last_check=%s url=%s http=%s", health, lastCheck, url, code)), nil }}, {tool: &mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl",