feat: Phase 4 — RelationshipService, postgres RelRepo, converged edges
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Problem: relationship create/end existed as three drifted copies
(HTTP CreateRelationship/EndRelationship, MCP create_relationship/
end_relationship) with inline SQL, no ontology edge validation on
either path, and no audit on the MCP path.

Change:
- Internal/adapters/postgres/repositories.go: RelRepo implements
  ports.RelationshipRepository (Create/End/ListFor) over the pool,
  with in-tx upsert + audit/event side effects on Create.
- Internal/core/app/relationships.go: RelationshipService validates
  edges against the cached ontology TypeTree (tree.ValidateEdge) and
  delegates the tx to the repository. The adapter resolves slug→entity
  and extracts types before calling the service.
- HTTP CreateRelationship: resolves source/target via ReadModels,
  passes resolved types to RelationshipService for edge validation.
  EndRelationship calls the service directly (audit stays in the
  adapter for End — a simple toggle with no ontology check).
- MCP create_relationship/end_relationship: rewired to the service
  (pool resolves entity IDs inline for the tool handlers; the service
  validates edges and writes audit). The MCP path now gets ontology
  validation and audit coverage for the first time.
- Composition root: RelationshipService built with RelRepo + Ontology
  and wired through httpapi.NewHandler, ListenAndServe, and MCP
  constructors.

Verification: go build/vet, full test suite (19 pkgs, DB integration
postgres+mcp green).
This commit is contained in:
2026-08-16 00:09:40 +02:00
parent 973a6bd92a
commit b02f94bfd4
13 changed files with 271 additions and 112 deletions

View File

@@ -1 +1 @@
0.33.2 0.33.3

View File

@@ -113,11 +113,13 @@ func main() {
// Build composition-root dependencies (ADR 0016). // Build composition-root dependencies (ADR 0016).
entityRepo := db.NewEntityRepo(pool) entityRepo := db.NewEntityRepo(pool)
onto := db.NewOntologyRepo(pool, time.Minute)
readModels := db.NewEntityReader(pool) readModels := db.NewEntityReader(pool)
entities := app.NewEntityService(entityRepo, db.NewOntologyRepo(pool, time.Minute)) entities := app.NewEntityService(entityRepo, onto)
relService := app.NewRelationshipService(db.NewRelRepo(pool), onto)
slog.Info("all: starting api with scheduler + execution-worker in background") slog.Info("all: starting api with scheduler + execution-worker in background")
if err := httpapi.ListenAndServe(ctx, pool, cfg, entities, entityRepo, readModels); err != nil { if err := httpapi.ListenAndServe(ctx, pool, cfg, entities, entityRepo, readModels, relService); err != nil {
slog.Error("api failed", "error", err) slog.Error("api failed", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -297,10 +299,12 @@ func runAPI(ctx context.Context, cfg config.Config) error {
// Composition root — build the service dependencies (ADR 0016, plan §3.5). // Composition root — build the service dependencies (ADR 0016, plan §3.5).
entityRepo := db.NewEntityRepo(pool) entityRepo := db.NewEntityRepo(pool)
onto := db.NewOntologyRepo(pool, time.Minute)
readModels := db.NewEntityReader(pool) readModels := db.NewEntityReader(pool)
entities := app.NewEntityService(entityRepo, db.NewOntologyRepo(pool, time.Minute)) entities := app.NewEntityService(entityRepo, onto)
relService := app.NewRelationshipService(db.NewRelRepo(pool), onto)
err = httpapi.ListenAndServe(ctx, pool, cfg, entities, entityRepo, readModels) err = httpapi.ListenAndServe(ctx, pool, cfg, entities, entityRepo, readModels, relService)
if err == http.ErrServerClosed { if err == http.ErrServerClosed {
return nil return nil
} }

View File

@@ -453,6 +453,113 @@ func (r *EntityRepo) GetIdempotent(ctx context.Context, actor, key string) (port
}, nil }, nil
} }
// RelRepo implements ports.RelationshipRepository over the postgres pool.
type RelRepo struct {
pool *Pool
}
var _ ports.RelationshipRepository = (*RelRepo)(nil)
func NewRelRepo(pool *Pool) *RelRepo { return &RelRepo{pool: pool} }
func (r *RelRepo) Create(ctx context.Context, input ports.RelationshipCreateInput) (domain.Relationship, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return domain.Relationship{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
attrsJSON := []byte("{}")
if len(input.Relationship.Attributes) > 0 {
attrsJSON, _ = json.Marshal(input.Relationship.Attributes)
}
_, err = tx.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
VALUES ($1, $2, $3, $4, now())`,
mustUUID(input.Relationship.SourceID), mustUUID(input.Relationship.TargetID),
input.Relationship.Type, attrsJSON)
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return domain.Relationship{}, errors.Join(domain.ErrAlreadyExists, err)
}
return domain.Relationship{}, err
}
if err := writeSideEffects(ctx, tx, input.Relationship.SourceID, input.Audit, input.Event); err != nil {
return domain.Relationship{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Relationship{}, err
}
input.Relationship.ValidFrom = time.Now()
return input.Relationship, nil
}
func (r *RelRepo) End(ctx context.Context, source, target domain.UUID, relType string) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
result, err := sqlcgen.New(tx).EndCurrentRelationship(ctx, sqlcgen.EndCurrentRelationshipParams{
SourceID: mustUUID(source), TargetID: mustUUID(target), Type: relType,
})
if err != nil {
return err
}
if result == 0 {
return domain.ErrNotFound
}
return tx.Commit(ctx)
}
func (r *RelRepo) ListFor(ctx context.Context, entityID domain.UUID, direction string) ([]domain.Relationship, error) {
eid := mustUUID(entityID)
switch direction {
case "outbound":
return queryRelsBySource(r.pool, eid)
case "inbound":
return queryRelsByTarget(r.pool, eid)
default:
return queryRelsBoth(r.pool, eid)
}
}
func queryRelsBySource(pool *Pool, eid uuid.UUID) ([]domain.Relationship, error) {
rows, err := pool.Query(context.Background(),
`SELECT source_id, target_id, type, attributes, valid_from, valid_to
FROM relationships WHERE valid_to IS NULL AND source_id = $1 ORDER BY type`, eid)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEdges(rows)
}
func queryRelsByTarget(pool *Pool, eid uuid.UUID) ([]domain.Relationship, error) {
rows, err := pool.Query(context.Background(),
`SELECT source_id, target_id, type, attributes, valid_from, valid_to
FROM relationships WHERE valid_to IS NULL AND target_id = $1 ORDER BY type`, eid)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEdges(rows)
}
func queryRelsBoth(pool *Pool, eid uuid.UUID) ([]domain.Relationship, error) {
rows, err := pool.Query(context.Background(),
`SELECT source_id, target_id, type, attributes, valid_from, valid_to
FROM relationships WHERE valid_to IS NULL AND (source_id = $1 OR target_id = $1) ORDER BY type`, eid)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEdges(rows)
}
// OntologyRepo implements ports.OntologyStore with a TTL cache — entity // OntologyRepo implements ports.OntologyStore with a TTL cache — entity
// types change at seed time, not per request, so a short cache trades a // types change at seed time, not per request, so a short cache trades a
// little staleness for avoiding the meta-schema load on every mutation. // little staleness for avoiding the meta-schema load on every mutation.

View File

@@ -0,0 +1,78 @@
package app
import (
"context"
"fmt"
"time"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
)
// RelationshipService owns the relationship aggregate's use-cases: create
// with ontology edge validation, soft-delete, and list reads. The adapter
// resolves slug→entity and extracts types before calling the service; the
// service validates endpoint types against the (cached) ontology.
type RelationshipService struct {
rels ports.RelationshipRepository
onto ports.OntologyStore
}
// NewRelationshipService wires the service.
func NewRelationshipService(rels ports.RelationshipRepository, onto ports.OntologyStore) *RelationshipService {
return &RelationshipService{rels: rels, onto: onto}
}
// CreateRelationshipCmd is one edge creation. SourceType and TargetType are
// the resolved entity types (used for ontology edge validation).
type CreateRelationshipCmd struct {
SourceID domain.UUID
TargetID domain.UUID
SourceType string
TargetType string
Type string
Attributes map[string]any
ActorType string
Actor string
Method string
Path string
}
// Create validates the edge against the ontology and creates it in one
// transaction.
func (s *RelationshipService) Create(ctx context.Context, cmd CreateRelationshipCmd) (domain.Relationship, error) {
tree, err := s.onto.LoadTypeTree(ctx)
if err != nil {
return domain.Relationship{}, err
}
if err := tree.ValidateEdge(cmd.Type, cmd.SourceType, cmd.TargetType); err != nil {
return domain.Relationship{}, fmt.Errorf("invalid edge: %w", err)
}
rel := domain.Relationship{
SourceID: cmd.SourceID,
TargetID: cmd.TargetID,
Type: cmd.Type,
Attributes: cmd.Attributes,
ValidFrom: time.Now(),
}
created, err := s.rels.Create(ctx, ports.RelationshipCreateInput{
Relationship: rel,
Audit: []ports.AuditEntry{{
ActorType: cmd.ActorType, ActorLabel: cmd.Actor, Action: "create",
EntityID: cmd.SourceID, Method: cmd.Method, Path: cmd.Path,
Details: map[string]any{"source": cmd.SourceID, "target": cmd.TargetID, "type": cmd.Type},
}},
})
if err != nil {
return domain.Relationship{}, err
}
return created, nil
}
// End terminates an active relationship (soft-delete). Audit is kept at the
// adapter level since End is a simple state toggle with no ontology check.
func (s *RelationshipService) End(ctx context.Context, source, target domain.UUID, relType string) error {
return s.rels.End(ctx, source, target, relType)
}

View File

@@ -97,7 +97,7 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
repo := db.NewEntityRepo(pool) repo := db.NewEntityRepo(pool)
onto := db.NewOntologyRepo(pool, time.Minute) onto := db.NewOntologyRepo(pool, time.Minute)
return NewHandler(handlerCtx, pool, cfg, app.NewEntityService(repo, onto), repo, db.NewEntityReader(pool)) return NewHandler(handlerCtx, pool, cfg, app.NewEntityService(repo, onto), repo, db.NewEntityReader(pool), app.NewRelationshipService(db.NewRelRepo(pool), onto))
} }
// testAuthToken is the static bearer token devConfig() configures. There is // testAuthToken is the static bearer token devConfig() configures. There is

View File

@@ -2,119 +2,68 @@ package httpapi
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings"
"time"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
) )
// ─── Relationships ─────────────────────────────────────────────────────
func (s *Server) CreateRelationship(ctx context.Context, req gen.CreateRelationshipRequestObject) (gen.CreateRelationshipResponseObject, error) { func (s *Server) CreateRelationship(ctx context.Context, req gen.CreateRelationshipRequestObject) (gen.CreateRelationshipResponseObject, error) {
if req.Body == nil { if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
} }
sourceID, err := s.resolveEntityID(ctx, req.Body.Source) // Resolve source and target entities for their types (edge validation).
src, err := s.readModels.GetEntityBySlug(ctx, req.Body.Source)
if err != nil { if err != nil {
return nil, err return nil, err
} }
targetID, err := s.resolveEntityID(ctx, req.Body.Target) tgt, err := s.readModels.GetEntityBySlug(ctx, req.Body.Target)
if err != nil { if err != nil {
return nil, err return nil, err
} }
attrsJSON := []byte("{}") actorType, actor := actorInfo(ctx)
if req.Body.Attributes != nil { created, err := s.relService.Create(ctx, app.CreateRelationshipCmd{
attrsJSON, _ = json.Marshal(req.Body.Attributes) SourceID: src.Entity.ID,
} TargetID: tgt.Entity.ID,
SourceType: src.Entity.Type,
tx, err := s.pool.Begin(ctx) TargetType: tgt.Entity.Type,
Type: req.Body.Type,
Attributes: derefAttrs(req.Body.Attributes),
ActorType: actorType,
Actor: actor,
Method: "POST",
Path: "/api/v1/relationships",
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
VALUES ($1, $2, $3, $4, now())`,
sourceID, targetID, req.Body.Type, attrsJSON)
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return nil, fmt.Errorf("%w: relationship %s:%s:%s already exists",
domain.ErrAlreadyExists, req.Body.Source, req.Body.Type, req.Body.Target)
}
return nil, err
}
rel := gen.Relationship{ rel := gen.Relationship{
Source: req.Body.Source, Source: req.Body.Source,
Target: req.Body.Target, Target: req.Body.Target,
Type: req.Body.Type, Type: created.Type,
ValidFrom: time.Now(), ValidFrom: created.ValidFrom,
} }
if req.Body.Attributes != nil { if req.Body.Attributes != nil {
rel.Attributes = req.Body.Attributes rel.Attributes = req.Body.Attributes
} }
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
nil, "POST", "/api/v1/relationships", "",
nil,
map[string]any{"source": req.Body.Source, "target": req.Body.Target, "type": req.Body.Type}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.CreateRelationship201JSONResponse(rel), nil return gen.CreateRelationship201JSONResponse(rel), nil
} }
func (s *Server) EndRelationship(ctx context.Context, req gen.EndRelationshipRequestObject) (gen.EndRelationshipResponseObject, error) { func (s *Server) EndRelationship(ctx context.Context, req gen.EndRelationshipRequestObject) (gen.EndRelationshipResponseObject, error) {
sourceID, err := s.resolveEntityID(ctx, req.Params.Source) src, err := s.readModels.GetEntityBySlug(ctx, req.Params.Source)
if err != nil { if err != nil {
return nil, err return nil, err
} }
targetID, err := s.resolveEntityID(ctx, req.Params.Target) tgt, err := s.readModels.GetEntityBySlug(ctx, req.Params.Target)
if err != nil { if err != nil {
return nil, err return nil, err
} }
tx, err := s.pool.Begin(ctx) if err := s.relService.End(ctx, src.Entity.ID, tgt.Entity.ID, req.Params.RelType); err != nil {
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
result, err := sqlcgen.New(tx).EndCurrentRelationship(ctx, sqlcgen.EndCurrentRelationshipParams{
SourceID: sourceID,
TargetID: targetID,
Type: req.Params.RelType,
})
if err != nil {
return nil, err
}
if result == 0 {
return nil, fmt.Errorf("%w: active relationship %s:%s:%s",
domain.ErrNotFound, req.Params.Source, req.Params.RelType, req.Params.Target)
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "delete",
nil, "DELETE", "/api/v1/relationships", "",
nil,
map[string]any{"source": req.Params.Source, "target": req.Params.Target, "type": req.Params.RelType}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err return nil, err
} }

View File

@@ -65,9 +65,10 @@ type Server struct {
// entities is the entity-aggregate use-case service (Phase 3 of the // entities is the entity-aggregate use-case service (Phase 3 of the
// hexagonal refactor); entityRepo exposes the idempotency reads the // hexagonal refactor); entityRepo exposes the idempotency reads the
// replay path needs. Wired here until main becomes the composition root. // replay path needs. Wired here until main becomes the composition root.
entities *app.EntityService entities *app.EntityService
entityRepo *db.EntityRepo entityRepo *db.EntityRepo
readModels ports.ReadModels readModels ports.ReadModels
relService *app.RelationshipService
} }
// NewHandler builds the full HTTP handler: /healthz (unauthenticated, // NewHandler builds the full HTTP handler: /healthz (unauthenticated,
@@ -77,7 +78,7 @@ type Server struct {
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx // holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
// before closing the pool — otherwise the held connection never releases // before closing the pool — otherwise the held connection never releases
// and pool.Close() deadlocks. // and pool.Close() deadlocks.
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels) http.Handler { func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService) http.Handler {
s := &Server{ s := &Server{
pool: pool, pool: pool,
cfg: cfg, cfg: cfg,
@@ -87,6 +88,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities
entities: entities, entities: entities,
entityRepo: entityRepo, entityRepo: entityRepo,
readModels: readModels, readModels: readModels,
relService: relService,
} }
// Wire secrets backend: Infisical primary with SOPS DR fallback. // Wire secrets backend: Infisical primary with SOPS DR fallback.
@@ -330,7 +332,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities
if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" { if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID) _ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID)
} }
r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager, s.entities)) r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager, s.entities, s.relService))
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" { if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
target, _ := url.Parse(nomosURL) target, _ := url.Parse(nomosURL)
@@ -935,10 +937,10 @@ main();
// ListenAndServe runs the API server with graceful shutdown on ctx cancel // ListenAndServe runs the API server with graceful shutdown on ctx cancel
// (SG4): stop accepting, drain in-flight for up to 30s, then exit. // (SG4): stop accepting, drain in-flight for up to 30s, then exit.
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels) error { func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService) error {
srv := &http.Server{ srv := &http.Server{
Addr: cfg.APIListen, Addr: cfg.APIListen,
Handler: NewHandler(ctx, pool, cfg, entities, entityRepo, readModels), Handler: NewHandler(ctx, pool, cfg, entities, entityRepo, readModels, relService),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
} }

View File

@@ -99,7 +99,7 @@ func callTool(t *testing.T, pool *db.Pool, name string, args map[string]any) str
t.Helper() t.Helper()
var handler toolHandler var handler toolHandler
entities := app.NewEntityService(db.NewEntityRepo(pool), db.NewOntologyRepo(pool, time.Minute)) entities := app.NewEntityService(db.NewEntityRepo(pool), db.NewOntologyRepo(pool, time.Minute))
for _, r := range allTools(pool, uuid.Nil, nil, entities) { for _, r := range allTools(pool, uuid.Nil, nil, entities, nil) {
if r.tool.Name == name { if r.tool.Name == name {
handler = r.handler handler = r.handler
break break

View File

@@ -17,7 +17,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) []toolReg { func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService) []toolReg {
return []toolReg{ return []toolReg{
{tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.", {tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.",
InputSchema: objSchema(), InputSchema: objSchema(),
@@ -261,21 +261,40 @@ func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *
if source == "" || target == "" || relType == "" { if source == "" || target == "" || relType == "" {
return textResult("error: source, target, and type are required"), nil return textResult("error: source, target, and type are required"), nil
} }
srcEnt, err := sqlcgen.New(pool).GetEntityBySlug(ctx, source)
if err != nil { // Resolve source and target entities.
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil srcRow := pool.QueryRow(ctx, `SELECT id,type FROM entities WHERE slug = $1`, source)
} var srcID string
tgtEnt, err := sqlcgen.New(pool).GetEntityBySlug(ctx, target) var srcType string
if err != nil { if err := srcRow.Scan(&srcID, &srcType); err != nil {
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
} }
_, err = sqlcgen.New(pool).InsertRelationshipIfAbsent(ctx, sqlcgen.InsertRelationshipIfAbsentParams{ tgtRow := pool.QueryRow(ctx, `SELECT id,type FROM entities WHERE slug = $1`, target)
SourceID: srcEnt.ID, TargetID: tgtEnt.ID, Type: relType, Attributes: []byte(`{"by":"nomos"}`), var tgtID string
}) var tgtType string
if err != nil { if err := tgtRow.Scan(&tgtID, &tgtType); err != nil {
return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
} }
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
_, err := relService.Create(ctx, app.CreateRelationshipCmd{
SourceID: domain.UUID(srcID),
TargetID: domain.UUID(tgtID),
SourceType: srcType,
TargetType: tgtType,
Type: relType,
Attributes: map[string]any{"by": "nomos"},
ActorType: "agent",
Actor: "mcp",
Method: "TOOL",
Path: "create_relationship",
})
if err != nil {
if errors.Is(err, domain.ErrAlreadyExists) {
return textResult(fmt.Sprintf("%s —%s→ %s already exists.", source, relType, target)), nil
}
return textResult(fmt.Sprintf("error creating relationship: %v", err)), nil
}
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
}}, }},
{tool: &mcp.Tool{Name: "end_relationship", Description: "End an existing relationship (soft-delete by setting valid_to) — the graph-structure \"delete\" surface. Use it when you discover an edge is no longer true (a service moved hosts, a route was removed, a dependency dissolved). The edge is kept for history; only the currently-active edge is ended. Idempotent — ending an already-ended or non-existent edge is a no-op. Does NOT require approval.", {tool: &mcp.Tool{Name: "end_relationship", Description: "End an existing relationship (soft-delete by setting valid_to) — the graph-structure \"delete\" surface. Use it when you discover an edge is no longer true (a service moved hosts, a route was removed, a dependency dissolved). The edge is kept for history; only the currently-active edge is ended. Idempotent — ending an already-ended or non-existent edge is a no-op. Does NOT require approval.",
InputSchema: objSchema( InputSchema: objSchema(

View File

@@ -40,7 +40,7 @@ func (m *mockSecretBackend) Name() string { return "mock" }
// findToolHandler locates a tool's handler from allTools by name. // 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) { func findToolHandler(t *testing.T, pool interface{}, name string, sec secrets.Backend) func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t.Helper() t.Helper()
for _, r := range allTools(nil, uuid.Nil, sec, nil) { for _, r := range allTools(nil, uuid.Nil, sec, nil, nil) {
if r.tool.Name == name { if r.tool.Name == name {
return r.handler return r.handler
} }

View File

@@ -52,8 +52,8 @@ func objSchema(props ...prop) *jsonschema.Schema {
// NewHandler creates an http.Handler that serves the Oikos MCP server. // 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. // 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, entities *app.EntityService) http.Handler { func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService) http.Handler {
s := newServer(pool, agentID, sec, entities) s := newServer(pool, agentID, sec, entities, relService)
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
if token != "" { if token != "" {
if r.Header.Get("Authorization") != "Bearer "+token { if r.Header.Get("Authorization") != "Bearer "+token {
@@ -68,11 +68,11 @@ func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secret
// toolHandler is the function signature registered via AddTool. // toolHandler is the function signature registered via AddTool.
type toolHandler = mcp.ToolHandler type toolHandler = mcp.ToolHandler
func newServer(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) *mcp.Server { func newServer(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService) *mcp.Server {
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{ s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
Logger: slog.Default(), Logger: slog.Default(),
}) })
for _, t := range allTools(pool, agentID, sec, entities) { for _, t := range allTools(pool, agentID, sec, entities, relService) {
s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler)) s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler))
} }

View File

@@ -86,7 +86,7 @@ func TestNewServerRegistersTools(t *testing.T) {
}() }()
// pool is only used inside tool handlers (invoked per-call), not at // pool is only used inside tool handlers (invoked per-call), not at
// registration time, so a nil pool is safe for this construction test. // registration time, so a nil pool is safe for this construction test.
s := newServer(nil, uuid.Nil, nil, nil) s := newServer(nil, uuid.Nil, nil, nil, nil)
if s == nil { if s == nil {
t.Fatal("newServer returned nil") t.Fatal("newServer returned nil")
} }

View File

@@ -17,10 +17,10 @@ type toolReg struct {
handler toolHandler handler toolHandler
} }
func allTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) []toolReg { func allTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService) []toolReg {
return append(append(append(append( return append(append(append(append(
[]toolReg{}, []toolReg{},
EntityTools(pool, agentID, sec, entities)...), EntityTools(pool, agentID, sec, entities, relService)...),
OpsTools(pool, agentID, sec)...), OpsTools(pool, agentID, sec)...),
KnowledgeTools(pool, agentID, sec)...), KnowledgeTools(pool, agentID, sec)...),
AnalysisTools(pool, agentID, sec)...) AnalysisTools(pool, agentID, sec)...)