feat: Phase 4 — RelationshipService, postgres RelRepo, converged edges
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:
@@ -113,11 +113,13 @@ func main() {
|
||||
|
||||
// Build composition-root dependencies (ADR 0016).
|
||||
entityRepo := db.NewEntityRepo(pool)
|
||||
onto := db.NewOntologyRepo(pool, time.Minute)
|
||||
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")
|
||||
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)
|
||||
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).
|
||||
entityRepo := db.NewEntityRepo(pool)
|
||||
onto := db.NewOntologyRepo(pool, time.Minute)
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -453,6 +453,113 @@ func (r *EntityRepo) GetIdempotent(ctx context.Context, actor, key string) (port
|
||||
}, 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
|
||||
// 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.
|
||||
|
||||
78
internal/core/app/relationships.go
Normal file
78
internal/core/app/relationships.go
Normal 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)
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
|
||||
|
||||
repo := db.NewEntityRepo(pool)
|
||||
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
|
||||
|
||||
@@ -2,119 +2,68 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||
"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/observability"
|
||||
)
|
||||
|
||||
// ─── Relationships ─────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) CreateRelationship(ctx context.Context, req gen.CreateRelationshipRequestObject) (gen.CreateRelationshipResponseObject, error) {
|
||||
if req.Body == nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
targetID, err := s.resolveEntityID(ctx, req.Body.Target)
|
||||
tgt, err := s.readModels.GetEntityBySlug(ctx, req.Body.Target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attrsJSON := []byte("{}")
|
||||
if req.Body.Attributes != nil {
|
||||
attrsJSON, _ = json.Marshal(req.Body.Attributes)
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
actorType, actor := actorInfo(ctx)
|
||||
created, err := s.relService.Create(ctx, app.CreateRelationshipCmd{
|
||||
SourceID: src.Entity.ID,
|
||||
TargetID: tgt.Entity.ID,
|
||||
SourceType: src.Entity.Type,
|
||||
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 {
|
||||
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{
|
||||
Source: req.Body.Source,
|
||||
Target: req.Body.Target,
|
||||
Type: req.Body.Type,
|
||||
ValidFrom: time.Now(),
|
||||
Type: created.Type,
|
||||
ValidFrom: created.ValidFrom,
|
||||
}
|
||||
if req.Body.Attributes != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
targetID, err := s.resolveEntityID(ctx, req.Params.Target)
|
||||
tgt, err := s.readModels.GetEntityBySlug(ctx, req.Params.Target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
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 {
|
||||
if err := s.relService.End(ctx, src.Entity.ID, tgt.Entity.ID, req.Params.RelType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ type Server struct {
|
||||
entities *app.EntityService
|
||||
entityRepo *db.EntityRepo
|
||||
readModels ports.ReadModels
|
||||
relService *app.RelationshipService
|
||||
}
|
||||
|
||||
// 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
|
||||
// before closing the pool — otherwise the held connection never releases
|
||||
// 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{
|
||||
pool: pool,
|
||||
cfg: cfg,
|
||||
@@ -87,6 +88,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities
|
||||
entities: entities,
|
||||
entityRepo: entityRepo,
|
||||
readModels: readModels,
|
||||
relService: relService,
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
_ = 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 != "" {
|
||||
target, _ := url.Parse(nomosURL)
|
||||
@@ -935,10 +937,10 @@ main();
|
||||
|
||||
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
|
||||
// (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{
|
||||
Addr: cfg.APIListen,
|
||||
Handler: NewHandler(ctx, pool, cfg, entities, entityRepo, readModels),
|
||||
Handler: NewHandler(ctx, pool, cfg, entities, entityRepo, readModels, relService),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ func callTool(t *testing.T, pool *db.Pool, name string, args map[string]any) str
|
||||
t.Helper()
|
||||
var handler toolHandler
|
||||
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 {
|
||||
handler = r.handler
|
||||
break
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"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{
|
||||
{tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.",
|
||||
InputSchema: objSchema(),
|
||||
@@ -261,19 +261,38 @@ func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *
|
||||
if source == "" || target == "" || relType == "" {
|
||||
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.
|
||||
srcRow := pool.QueryRow(ctx, `SELECT id,type FROM entities WHERE slug = $1`, source)
|
||||
var srcID string
|
||||
var srcType string
|
||||
if err := srcRow.Scan(&srcID, &srcType); err != nil {
|
||||
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
|
||||
}
|
||||
tgtEnt, err := sqlcgen.New(pool).GetEntityBySlug(ctx, target)
|
||||
if err != nil {
|
||||
tgtRow := pool.QueryRow(ctx, `SELECT id,type FROM entities WHERE slug = $1`, target)
|
||||
var tgtID string
|
||||
var tgtType string
|
||||
if err := tgtRow.Scan(&tgtID, &tgtType); err != nil {
|
||||
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
|
||||
}
|
||||
_, err = sqlcgen.New(pool).InsertRelationshipIfAbsent(ctx, sqlcgen.InsertRelationshipIfAbsentParams{
|
||||
SourceID: srcEnt.ID, TargetID: tgtEnt.ID, Type: relType, Attributes: []byte(`{"by":"nomos"}`),
|
||||
|
||||
_, 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 {
|
||||
return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), 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
|
||||
}},
|
||||
|
||||
@@ -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, nil) {
|
||||
for _, r := range allTools(nil, uuid.Nil, sec, nil, nil) {
|
||||
if r.tool.Name == name {
|
||||
return r.handler
|
||||
}
|
||||
|
||||
@@ -52,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, entities *app.EntityService) http.Handler {
|
||||
s := newServer(pool, agentID, sec, entities)
|
||||
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, relService)
|
||||
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
||||
if 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.
|
||||
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{
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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, nil)
|
||||
s := newServer(nil, uuid.Nil, 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, 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(
|
||||
[]toolReg{},
|
||||
EntityTools(pool, agentID, sec, entities)...),
|
||||
EntityTools(pool, agentID, sec, entities, relService)...),
|
||||
OpsTools(pool, agentID, sec)...),
|
||||
KnowledgeTools(pool, agentID, sec)...),
|
||||
AnalysisTools(pool, agentID, sec)...)
|
||||
|
||||
Reference in New Issue
Block a user