- create_entity, set_entity_state, end_relationship MCP tools - update_entity_attributes now triggers check derivation via EnsureEntityChecks - shared db.EnsureEntityChecks + db.ValidateTransition hooks (HTTP + MCP parity) - curl -o /dev/null now classified read_only (was config_mutation) - db.ErrTransitionInvalid sentinel for HTTP error-type accuracy - SOUL.md: capability escalation, self-grounding, exploration budget rules - Runbook: oikos check lifecycle for agent self-knowledge
252 lines
8.5 KiB
Go
252 lines
8.5 KiB
Go
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")
|
|
}
|
|
}
|