package mcp // Integration tests for the entity-mutation MCP tools (create_entity, // update_entity_attributes), focused on the capability gap that stranded // session 23da10db: entities mutated via MCP must derive/regenerate checks the // same way the HTTP create/patch paths do. Guarded by OIKOS_TEST_DATABASE_URL // (see internal/db/integration_test.go); run via `make test-db`. import ( "context" "encoding/json" "fmt" "math/rand" "os" "strings" "testing" "github.com/dtoro/oikos/internal/checkdefaults" "github.com/dtoro/oikos/internal/db" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/modelcontextprotocol/go-sdk/mcp" ) // newTestPool mirrors internal/httpapi/api_test.go: a throwaway database, // migrated and seeded with ontology/inventory/policy so create_entity's type // validation and checkdefaults derivation have a real type tree to work // against. func newTestPool(t *testing.T) *db.Pool { t.Helper() baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL") if baseURL == "" { t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test") } ctx := context.Background() admin, err := pgx.Connect(ctx, baseURL) if err != nil { t.Fatalf("connect admin: %v", err) } dbName := fmt.Sprintf("oikos_mcp_test_%08x", rand.Int63()) if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil { admin.Close(ctx) t.Fatalf("create test db: %v", err) } admin.Close(ctx) qi := strings.Index(baseURL, "?") base, params := baseURL, "" if qi >= 0 { base, params = baseURL[:qi], baseURL[qi:] } testURL := base[:strings.LastIndex(base, "/")+1] + dbName + params pool, err := db.New(ctx, testURL) if err != nil { t.Fatalf("connect test db: %v", err) } t.Cleanup(func() { pool.Close() if admin, e := pgx.Connect(ctx, baseURL); e == nil { admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)") admin.Close(ctx) } }) if err := pool.Migrate(ctx); err != nil { t.Fatalf("migrate: %v", err) } for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} { content, err := os.ReadFile("../../seeds/" + f) if err != nil { t.Fatalf("read seed %s: %v", f, err) } name := f if err := pool.SeedIngest(ctx, name, content, func(ctx context.Context, tx pgx.Tx, data map[string]any) error { var err error switch name { case "ontology.yaml": _, err = db.IngestOntologySeed(ctx, tx, data) case "inventory.yaml": _, err = db.IngestInventorySeed(ctx, tx, data) case "policy.yaml": _, err = db.IngestPolicySeed(ctx, tx, data) } return err }); err != nil { t.Fatalf("ingest %s: %v", f, err) } } return pool } // callTool invokes a registered tool's handler in-process and returns its // concatenated text result. func callTool(t *testing.T, pool *db.Pool, name string, args map[string]any) string { t.Helper() var handler toolHandler for _, r := range allTools(pool, uuid.Nil) { if r.tool.Name == name { handler = r.handler break } } if handler == nil { t.Fatalf("tool %q not registered", name) } argsJSON, _ := json.Marshal(args) res, err := handler(context.Background(), &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{ Name: name, Arguments: argsJSON, }}) if err != nil { t.Fatalf("tool %s returned error: %v", name, err) } var sb strings.Builder for _, c := range res.Content { if tc, ok := c.(*mcp.TextContent); ok { sb.WriteString(tc.Text) } } return sb.String() } // checkCountFor returns the number of derived check_defs targeting slug. func checkCountFor(t *testing.T, pool *db.Pool, slug string) int { t.Helper() var n int err := pool.QueryRow(context.Background(), `SELECT count(*) FROM check_defs cd JOIN entities e ON e.id = cd.target_id WHERE e.slug = $1`, slug).Scan(&n) if err != nil { t.Fatalf("count check_defs for %s: %v", slug, err) } return n } // TestCreateEntity_DerivesChecks proves create_entity inserts an entity AND // derives its default checks in one call (the HTTP create path did this; the // MCP path previously could not create at all). func TestCreateEntity_DerivesChecks(t *testing.T) { pool := newTestPool(t) slug := "service:mcp-create-test" out := callTool(t, pool, "create_entity", map[string]any{ "type": "service", "slug": slug, "name": "mcp-create-test", "attributes": `{"url":"https://mcp-create-test.example"}`, }) if !strings.Contains(out, "Created "+slug) { t.Fatalf("create_entity result = %q, want Created %s", out, slug) } if !strings.Contains(out, "Derived") { t.Errorf("create_entity result = %q, want a Derived check summary", out) } if got := checkCountFor(t, pool, slug); got < 1 { t.Errorf("check_defs targeting %s = %d, want >=1 (create did not derive checks)", slug, got) } } // TestCreateEntity_DuplicateAndInvalid covers the guard rails: a repeat create // is reported as "already exists" (not an error), and an unknown type is // rejected with a clear message. func TestCreateEntity_DuplicateAndInvalid(t *testing.T) { pool := newTestPool(t) if out := callTool(t, pool, "create_entity", map[string]any{ "type": "service", "slug": "service:mcp-dup", "name": "mcp-dup", }); !strings.Contains(out, "Created service:mcp-dup") { t.Fatalf("first create = %q", out) } if out := callTool(t, pool, "create_entity", map[string]any{ "type": "service", "slug": "service:mcp-dup", "name": "mcp-dup", }); !strings.Contains(out, "already exists") { t.Errorf("duplicate create = %q, want 'already exists'", out) } if out := callTool(t, pool, "create_entity", map[string]any{ "type": "no-such-type", "slug": "no-such-type:x", "name": "x", }); !strings.Contains(out, "not found in ontology") { t.Errorf("unknown type = %q, want 'not found in ontology'", out) } } // TestUpdateEntityAttributes_RegeneratesChecks is the regression guard for the // haos session: setting an entity's `monitoring` attribute via MCP must // regenerate checks. Before this fix the MCP update path skipped // ensureDefaultChecks, so flipping monitoring produced nothing. func TestUpdateEntityAttributes_RegeneratesChecks(t *testing.T) { pool := newTestPool(t) slug := "service:mcp-regen-test" // Create with monitoring:none — no checks derived. if out := callTool(t, pool, "create_entity", map[string]any{ "type": "service", "slug": slug, "name": "mcp-regen-test", "attributes": `{"monitoring":"none","url":"https://mcp-regen.example"}`, }); !strings.Contains(out, "Created "+slug) { t.Fatalf("create = %q", out) } if got := checkCountFor(t, pool, slug); got != 0 { t.Fatalf("check_defs with monitoring:none = %d, want 0", got) } // Flip monitoring to [http] via update_entity_attributes — checks must // regenerate. This is exactly what failed for service:haos. out := callTool(t, pool, "update_entity_attributes", map[string]any{ "slug": slug, "attributes": `{"monitoring":["http"]}`, }) if !strings.Contains(out, "Updated "+slug) { t.Fatalf("update result = %q, want Updated %s", out, slug) } if !strings.Contains(out, "Derived") { t.Errorf("update result = %q, want a Derived check summary (regeneration)", out) } if got := checkCountFor(t, pool, slug); got < 1 { t.Errorf("check_defs after monitoring:[http] = %d, want >=1 (MCP update did not regenerate checks)", got) } } // TestUpdateEntityAttributes_NotFound keeps the existing error contract. func TestUpdateEntityAttributes_NotFound(t *testing.T) { pool := newTestPool(t) out := callTool(t, pool, "update_entity_attributes", map[string]any{ "slug": "service:does-not-exist", "attributes": `{"x":1}`, }) if !strings.Contains(out, "not found") { t.Errorf("update missing entity = %q, want 'not found'", out) } } // TestFormatCheckResult is a pure unit test for the result-message helper, so // the formatting contract holds even when the DB is unavailable. func TestFormatCheckResult(t *testing.T) { if got := formatCheckResult(checkdefaults.Result{Created: 2}); !strings.Contains(got, "Derived 2 check") { t.Errorf("created-only = %q, want Derived 2", got) } got := formatCheckResult(checkdefaults.Result{Created: 1, Skipped: []checkdefaults.Skip{{Kind: "process", Reason: "no host"}}}) if !strings.Contains(got, "Derived 1 check") || !strings.Contains(got, "Skipped process") || !strings.Contains(got, "no host") { t.Errorf("created+skipped = %q", got) } if got := formatCheckResult(checkdefaults.Result{Undeclared: true}); !strings.Contains(got, "no monitoring") { t.Errorf("undeclared = %q, want no-monitoring hint", got) } if formatCreateResult("a", "b", checkdefaults.Result{Created: 0}) != "Created a (b)." { t.Error("create result with no checks should have no suffix") } }