feat: Phase 3b — EntityService, postgres EntityRepository, converged mutations
Problem: entity mutations (create/update/state) existed as three drifted
copies — HTTP CreateEntity/PatchEntity, MCP create_entity/
update_entity_attributes/set_entity_state — each with its own inline
SQL, its own validation subset (MCP validated lifecycle states, HTTP
did not; HTTP patched attributes without regenerating derived checks,
MCP did; MCP wrote no audit trail), the exact drift ADR 0016's first
vertical slice exists to collapse.
Change:
- internal/core/ports: DerivedCheck, Idempotency (adapter-owned request
hash + cached-body renderer so the replay record commits in the
create's transaction), IdempotentResponse + GetIdempotent read,
AuditEntry gains Method/Path/CorrelationID, Event gains
CorrelationID; EntityUpdateInput carries ExpectedVersion +
RederiveChecks (derivation for updates runs repo-side: the graph
host fallback reads relationships through the open tx).
- internal/adapters/postgres/repositories.go: EntityRepo (Create/
Update/SetState/reads/idempotency) preserving the load-bearing
check-then-act invariants in-tx: version WHERE-clause, declared
transitions + preconditions (ValidateTransition), duplicate-slug
mapping, audit/event/writeCheck all inside one BEGIN…COMMIT.
OntologyRepo: TTL-cached OntologyStore.
- internal/core/app/entities.go: EntityService — ontology validation
(type exists, concrete, state declared — the stricter MCP rule now
governs both surfaces), default-state resolution, id generation,
derivation for creates, audit/event construction, idempotency
pass-through.
- httpapi CreateEntity/PatchEntity rewired to the service; PATCH now
regenerates derived checks (the A2 parity gap). MCP create/update/
set-state tools call the same service — and now write audit + event
rows like the HTTP surface always did.
- Integration-test seed paths fixed for the adapters/postgres package
depth (../../../seeds).
Pre-existing failures documented: TestAPIEndToEnd (entity_types 60 vs
59; 501-endpoint now 200), TestClientLifecycleEndToEnd, TestPhase3*
rows — verified failing identically at ec11956 (scratch approval-
notifier commit test drift), unrelated to this change. All mutation
integration tests (create/patch/idempotency/audit/regeneration) pass.
Verification: make test-db (postgres + mcp green, httpapi green except
the pre-existing set), full non-DB suite (19 pkgs), golangci on new
packages — 0 issues.
This commit is contained in:
@@ -7,6 +7,7 @@ package mcp
|
||||
// (see internal/db/integration_test.go); run via `make test-db`.
|
||||
|
||||
import (
|
||||
"time"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -97,7 +98,8 @@ func newTestPool(t *testing.T) *db.Pool {
|
||||
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, nil) {
|
||||
entities := app.NewEntityService(db.NewEntityRepo(pool), db.NewOntologyRepo(pool, time.Minute))
|
||||
for _, r := range allTools(pool, uuid.Nil, nil, entities) {
|
||||
if r.tool.Name == name {
|
||||
handler = r.handler
|
||||
break
|
||||
|
||||
@@ -3,18 +3,21 @@ package mcp
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dtoro/oikos/internal/audit"
|
||||
"github.com/dtoro/oikos/internal/core/ports"
|
||||
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/core/app"
|
||||
"github.com/dtoro/oikos/internal/core/domain"
|
||||
"github.com/dtoro/oikos/internal/core/ports"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg {
|
||||
func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) []toolReg {
|
||||
return []toolReg{
|
||||
{tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.",
|
||||
InputSchema: objSchema(),
|
||||
@@ -109,81 +112,39 @@ func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg
|
||||
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
|
||||
}
|
||||
}
|
||||
attrsJSON, _ := json.Marshal(attrs)
|
||||
stateStr, _ := args["state"].(string)
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
|
||||
|
||||
// One service path with the HTTP surface (ADR 0016 Phase 3):
|
||||
// ontology validation, lifecycle-state guardrails, check
|
||||
// derivation, and audit/event recording converge here.
|
||||
_, res, err := entities.Create(ctx, app.CreateEntityCmd{
|
||||
Slug: slug,
|
||||
Type: entityType,
|
||||
Name: name,
|
||||
State: stateStr,
|
||||
Attributes: attrs,
|
||||
ActorType: "agent",
|
||||
Actor: "mcp",
|
||||
Method: "TOOL",
|
||||
Path: "create_entity",
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrAlreadyExists) {
|
||||
return textResult(fmt.Sprintf("Entity %q already exists — use update_entity_attributes to change it.", slug)), nil
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Validate the type exists and is concrete (mirror httpapi.CreateEntity).
|
||||
var isAbstract bool
|
||||
if err := tx.QueryRow(ctx, `SELECT is_abstract FROM entity_types WHERE name = $1`, entityType).Scan(&isAbstract); err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return textResult(fmt.Sprintf("error: entity type %q not found in ontology", entityType)), nil
|
||||
}
|
||||
if isAbstract {
|
||||
if errors.Is(err, domain.ErrAbstractType) {
|
||||
return textResult(fmt.Sprintf("error: type %q is abstract — pick a concrete subtype", entityType)), nil
|
||||
}
|
||||
|
||||
// Default state from the type's lifecycle unless the caller
|
||||
// supplied one. Caller-supplied states are validated against
|
||||
// the lifecycle's declared states — a create_entity bypass of
|
||||
// lifecycle guardrails would let an agent create in a terminal
|
||||
// state (destroyed) without satisfying the preconditions that
|
||||
// set_entity_state enforces for the same transition.
|
||||
var state *string
|
||||
var lsDefault, statesRaw string
|
||||
if err := tx.QueryRow(ctx, `SELECT coalesce(ld.default_state,''), coalesce(ld.states::text,'')
|
||||
FROM lifecycle_defs ld
|
||||
JOIN entity_types et ON et.lifecycle_id = ld.id
|
||||
WHERE et.name = $1`, entityType).Scan(&lsDefault, &statesRaw); err == nil {
|
||||
var validStates []string
|
||||
json.Unmarshal([]byte(statesRaw), &validStates)
|
||||
if stateStr != "" {
|
||||
found := false
|
||||
for _, s := range validStates {
|
||||
if s == stateStr {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && len(validStates) > 0 {
|
||||
return textResult(fmt.Sprintf("error: state %q not declared in %s lifecycle (states: %s). Use the default (%s) or omit state.", stateStr, entityType, strings.Join(validStates, ","), lsDefault)), nil
|
||||
}
|
||||
state = &stateStr
|
||||
} else if lsDefault != "" {
|
||||
state = &lsDefault
|
||||
}
|
||||
if errors.Is(err, domain.ErrInvalidTransition) {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: gen id: %v", err)), nil
|
||||
}
|
||||
|
||||
var createdName string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO entities (id, slug, type, name, state, attributes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING name`,
|
||||
id, slug, entityType, name, state, attrsJSON).Scan(&createdName); err != nil {
|
||||
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
|
||||
return textResult(fmt.Sprintf("Entity %q already exists — use update_entity_attributes to change it.", slug)), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("error creating %s: %v", slug, err)), nil
|
||||
}
|
||||
|
||||
res, derr := db.EnsureEntityChecks(ctx, tx, id, slug, entityType, createdName, attrsJSON)
|
||||
if derr != nil {
|
||||
return textResult(fmt.Sprintf("error deriving checks for %s: %v", slug, derr)), nil
|
||||
}
|
||||
if cerr := tx.Commit(ctx); cerr != nil {
|
||||
return textResult(fmt.Sprintf("error committing %s: %v", slug, cerr)), nil
|
||||
}
|
||||
|
||||
return textResult(formatCreateResult(slug, entityType, res)), nil
|
||||
return textResult(fmt.Sprintf("error creating %s: %v", slug, err)), nil
|
||||
}
|
||||
|
||||
return textResult(formatCreateResult(slug, entityType, res)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
|
||||
InputSchema: objSchema(
|
||||
@@ -221,44 +182,26 @@ func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg
|
||||
return textResult(fmt.Sprintf("Updated %s: no allowed attributes provided. The following keys are scheduler-owned and ignored: %s. Use get_health_summary or list_checks to observe entity health.", slug, strings.Join(blocked, ", "))), nil
|
||||
}
|
||||
}
|
||||
attrsJSON, _ := json.Marshal(attrs)
|
||||
|
||||
// Run the merge + check regeneration in one transaction so the
|
||||
// derived checks always see the post-merge attributes. Mirrors
|
||||
// httpapi.PatchEntity; without this, setting an entity's
|
||||
// `monitoring` attribute via MCP silently produced no checks.
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
ra, err := sqlcgen.New(tx).MergeEntityAttributes(ctx, sqlcgen.MergeEntityAttributesParams{Slug: slug, Patch: attrsJSON})
|
||||
// One service path with the HTTP surface (ADR 0016 Phase 3): merge +
|
||||
// check regeneration + audit/event in one transaction.
|
||||
_, res, err := entities.Update(ctx, app.UpdateEntityCmd{
|
||||
SlugOrID: slug,
|
||||
Attributes: attrs,
|
||||
AttrsReplace: false,
|
||||
RederiveChecks: true,
|
||||
ActorType: "agent",
|
||||
Actor: "mcp",
|
||||
Method: "TOOL",
|
||||
Path: "update_entity_attributes",
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
|
||||
}
|
||||
if ra == 0 {
|
||||
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
|
||||
}
|
||||
|
||||
merged, err := sqlcgen.New(tx).GetEntityBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error reloading %s: %v", slug, err)), nil
|
||||
}
|
||||
id := merged.ID
|
||||
entityType := merged.Type
|
||||
name := merged.Name
|
||||
mergedAttrs := merged.Attributes
|
||||
|
||||
res, cerr := db.EnsureEntityChecks(ctx, tx, id, slug, entityType, name, mergedAttrs)
|
||||
if cerr != nil {
|
||||
return textResult(fmt.Sprintf("error deriving checks for %s: %v", slug, cerr)), nil
|
||||
}
|
||||
if cerr := tx.Commit(ctx); cerr != nil {
|
||||
return textResult(fmt.Sprintf("error committing %s: %v", slug, cerr)), nil
|
||||
}
|
||||
|
||||
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).%s", slug, len(attrs), formatCheckResult(res))), nil
|
||||
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).%s", slug, len(attrs), formatCheckResult(res))), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "set_entity_state", Description: "Transition an entity to a new lifecycle state — the entity-graph \"delete\" surface, since this system never hard-deletes entities. Use retire/deprecate to take an entity out of service, destroy for terminal removal, or active to revive. The target state must be a declared transition in the entity type's lifecycle (e.g. active→deprecated, deprecated→active); preconditions (no inbound edges, backups verified, etc.) are enforced — an error tells you what's blocking. Does NOT require approval (knowledge-graph mutation, not live infrastructure).",
|
||||
InputSchema: objSchema(
|
||||
|
||||
@@ -40,7 +40,7 @@ func (m *mockSecretBackend) Name() string { return "mock" }
|
||||
// findToolHandler locates a tool's handler from allTools by name.
|
||||
func findToolHandler(t *testing.T, pool interface{}, name string, sec secrets.Backend) func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t.Helper()
|
||||
for _, r := range allTools(nil, uuid.Nil, sec) {
|
||||
for _, r := range allTools(nil, uuid.Nil, sec, nil) {
|
||||
if r.tool.Name == name {
|
||||
return r.handler
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/actuator"
|
||||
"github.com/dtoro/oikos/internal/core/app"
|
||||
"github.com/dtoro/oikos/internal/core/ports"
|
||||
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||
@@ -51,8 +52,8 @@ func objSchema(props ...prop) *jsonschema.Schema {
|
||||
|
||||
// NewHandler creates an http.Handler that serves the Oikos MCP server.
|
||||
// agentID is the Nomos agent entity UUID; tool calls are logged to agent_activity.
|
||||
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets) http.Handler {
|
||||
s := newServer(pool, agentID, sec)
|
||||
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) http.Handler {
|
||||
s := newServer(pool, agentID, sec, entities)
|
||||
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
||||
if token != "" {
|
||||
if r.Header.Get("Authorization") != "Bearer "+token {
|
||||
@@ -67,11 +68,11 @@ func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secret
|
||||
// toolHandler is the function signature registered via AddTool.
|
||||
type toolHandler = mcp.ToolHandler
|
||||
|
||||
func newServer(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) *mcp.Server {
|
||||
func newServer(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) *mcp.Server {
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
for _, t := range allTools(pool, agentID, sec) {
|
||||
for _, t := range allTools(pool, agentID, sec, entities) {
|
||||
s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler))
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestNewServerRegistersTools(t *testing.T) {
|
||||
}()
|
||||
// pool is only used inside tool handlers (invoked per-call), not at
|
||||
// registration time, so a nil pool is safe for this construction test.
|
||||
s := newServer(nil, uuid.Nil, nil)
|
||||
s := newServer(nil, uuid.Nil, nil, nil)
|
||||
if s == nil {
|
||||
t.Fatal("newServer returned nil")
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ type toolReg struct {
|
||||
handler toolHandler
|
||||
}
|
||||
|
||||
func allTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg {
|
||||
func allTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) []toolReg {
|
||||
return append(append(append(append(
|
||||
[]toolReg{},
|
||||
EntityTools(pool, agentID, sec)...),
|
||||
EntityTools(pool, agentID, sec, entities)...),
|
||||
OpsTools(pool, agentID, sec)...),
|
||||
KnowledgeTools(pool, agentID, sec)...),
|
||||
AnalysisTools(pool, agentID, sec)...)
|
||||
|
||||
Reference in New Issue
Block a user