From b02f94bfd4eeb98c06cbe5bc58de6d61eae50894 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 16 Aug 2026 00:09:40 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20RelationshipSer?= =?UTF-8?q?vice,=20postgres=20RelRepo,=20converged=20edges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- VERSION | 2 +- cmd/oikos/main.go | 12 ++- internal/adapters/postgres/repositories.go | 107 +++++++++++++++++++++ internal/core/app/relationships.go | 78 +++++++++++++++ internal/httpapi/api_test.go | 2 +- internal/httpapi/relationships.go | 97 +++++-------------- internal/httpapi/server.go | 16 +-- internal/mcp/create_entity_test.go | 2 +- internal/mcp/entity_tools.go | 51 +++++++--- internal/mcp/secrets_tools_test.go | 2 +- internal/mcp/server.go | 8 +- internal/mcp/server_test.go | 2 +- internal/mcp/tools.go | 4 +- 13 files changed, 271 insertions(+), 112 deletions(-) create mode 100644 internal/core/app/relationships.go diff --git a/VERSION b/VERSION index c9ec1d54..90d7d2b4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.33.2 +0.33.3 diff --git a/cmd/oikos/main.go b/cmd/oikos/main.go index 10dc2edc..ecf39b49 100644 --- a/cmd/oikos/main.go +++ b/cmd/oikos/main.go @@ -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 } diff --git a/internal/adapters/postgres/repositories.go b/internal/adapters/postgres/repositories.go index c7cad321..9c5e15e4 100644 --- a/internal/adapters/postgres/repositories.go +++ b/internal/adapters/postgres/repositories.go @@ -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. diff --git a/internal/core/app/relationships.go b/internal/core/app/relationships.go new file mode 100644 index 00000000..2ca5b541 --- /dev/null +++ b/internal/core/app/relationships.go @@ -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) +} \ No newline at end of file diff --git a/internal/httpapi/api_test.go b/internal/httpapi/api_test.go index fb0346de..803f2f5a 100644 --- a/internal/httpapi/api_test.go +++ b/internal/httpapi/api_test.go @@ -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 diff --git a/internal/httpapi/relationships.go b/internal/httpapi/relationships.go index 73e6836b..239271aa 100644 --- a/internal/httpapi/relationships.go +++ b/internal/httpapi/relationships.go @@ -2,121 +2,70 @@ 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 } return gen.EndRelationship204Response{}, nil -} +} \ No newline at end of file diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 89c19ab2..48a694e5 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -65,9 +65,10 @@ type Server struct { // entities is the entity-aggregate use-case service (Phase 3 of the // hexagonal refactor); entityRepo exposes the idempotency reads the // replay path needs. Wired here until main becomes the composition root. - entities *app.EntityService - entityRepo *db.EntityRepo - readModels ports.ReadModels + 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, } diff --git a/internal/mcp/create_entity_test.go b/internal/mcp/create_entity_test.go index 2d8564af..f3fc390a 100644 --- a/internal/mcp/create_entity_test.go +++ b/internal/mcp/create_entity_test.go @@ -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 diff --git a/internal/mcp/entity_tools.go b/internal/mcp/entity_tools.go index 174a58e4..81e06983 100644 --- a/internal/mcp/entity_tools.go +++ b/internal/mcp/entity_tools.go @@ -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,21 +261,40 @@ 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 { - return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil - } - tgtEnt, err := sqlcgen.New(pool).GetEntityBySlug(ctx, target) - if 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"}`), - }) - if err != nil { - return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil - } - return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), 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 + } + 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 := 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.", InputSchema: objSchema( diff --git a/internal/mcp/secrets_tools_test.go b/internal/mcp/secrets_tools_test.go index 80553235..36af55c8 100644 --- a/internal/mcp/secrets_tools_test.go +++ b/internal/mcp/secrets_tools_test.go @@ -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 } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 84fbe795..dd9bee29 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -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)) } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index a1c3dc00..341a5c82 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -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") } diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index e332a7b3..8708f523 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -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)...)