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:
@@ -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,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(
|
||||
|
||||
@@ -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