// Package mcp implements the Oikos MCP interface (plan R3-10). // Uses the official MCP Go SDK with Streamable HTTP transport. package mcp import ( "bytes" "context" "encoding/json" "fmt" "html" "io" "log/slog" "net" "net/http" "net/url" "os" "regexp" "strings" "sync" "time" "github.com/dtoro/oikos/internal/actuator" "github.com/dtoro/oikos/internal/core/app" "github.com/dtoro/oikos/internal/core/domain" "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/execlog" "github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/policy" "github.com/dtoro/oikos/internal/remote" "github.com/google/jsonschema-go/jsonschema" "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp" "golang.org/x/crypto/ssh" ) // prop is one input-schema property (name → type + description). type prop struct { name, typ, desc string } // objSchema builds an "object" JSON Schema from a list of properties. The // MCP SDK requires every tool to declare an object input schema so tools // are self-describing to the agent; a nil schema panics at registration. func objSchema(props ...prop) *jsonschema.Schema { s := &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{}} for _, p := range props { s.Properties[p.name] = &jsonschema.Schema{Type: p.typ, Description: p.desc} } return s } // 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, relService *app.RelationshipService, execSvc *app.ExecutionService) http.Handler { s := newServer(pool, agentID, sec, entities, relService, execSvc) handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { if token != "" { if r.Header.Get("Authorization") != "Bearer "+token { return nil } } return s }, nil) return handler } // 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, relService *app.RelationshipService, execSvc *app.ExecutionService) *mcp.Server { s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{ Logger: slog.Default(), }) for _, t := range allTools(pool, agentID, sec, entities, relService, execSvc) { s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler)) } // Resource templates: let MCP clients browse and attach entities, // knowledge entries, and executions as conversation resources. s.AddResourceTemplate(&mcp.ResourceTemplate{ URITemplate: "oikos://entity/{slug}", Name: "Entity", Description: "Oikos entity by slug (e.g. host:hubris, lxc:jellyfin)", MIMEType: "application/json", }, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) { slug := matches["slug"] var id uuid.UUID if u, err := uuid.Parse(slug); err == nil { id = u } else { pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id) } if id == uuid.Nil { return "", fmt.Errorf("entity not found: %s", slug) } result := queryEntity(ctx, pool, slug) return result.Content[0].(*mcp.TextContent).Text, nil })) s.AddResourceTemplate(&mcp.ResourceTemplate{ URITemplate: "oikos://knowledge/{id}", Name: "Knowledge", Description: "Knowledge entry by entity slug or UUID", MIMEType: "application/json", }, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) { idOrSlug := matches["id"] var entityID uuid.UUID if u, err := uuid.Parse(idOrSlug); err == nil { entityID = u } else { pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&entityID) } if entityID == uuid.Nil { return "", fmt.Errorf("knowledge not found: %s", idOrSlug) } result := queryRows(ctx, pool, ` SELECT ke.title, ke.content, ke.tags::text, e.slug, e.type AS kind, ke.updated_at::text FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id WHERE ke.entity_id = $1`, entityID) return result.Content[0].(*mcp.TextContent).Text, nil })) s.AddResourceTemplate(&mcp.ResourceTemplate{ URITemplate: "oikos://execution/{id}", Name: "Execution", Description: "Execution by UUID (returns status, result, timing)", MIMEType: "application/json", }, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) { result := queryRows(ctx, pool, ` SELECT e.entity_id, te.slug AS target, e.action, e.risk_class, e.status, e.result::text, e.duration_ms, e.started_at::text, e.completed_at::text FROM executions e JOIN entities te ON te.id = e.target_entity_id WHERE e.entity_id = $1`, matches["id"]) return result.Content[0].(*mcp.TextContent).Text, nil })) return s } // resourceHandler adapts a simple func(ctx, params) → (string, error) into // an MCP ResourceHandler, reading the URI matched by a ResourceTemplate. func resourceHandler(pool *db.Pool, fn func(ctx context.Context, matches map[string]string) (string, error)) mcp.ResourceHandler { return func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { uri := req.Params.URI matches := matchURITemplate(uri) if matches == nil { return nil, mcp.ResourceNotFoundError(uri) } text, err := fn(ctx, matches) if err != nil { return nil, mcp.ResourceNotFoundError(uri) } result, err := json.MarshalIndent(json.RawMessage(text), "", " ") if err != nil { result = []byte(text) } return &mcp.ReadResourceResult{ Contents: []*mcp.ResourceContents{{ URI: uri, MIMEType: "application/json", Text: string(result), }}, }, nil } } // matchURITemplate extracts parameters from a URI that matches one of the // oikos:// resource templates. Returns nil if the URI doesn't match. func matchURITemplate(uri string) map[string]string { // oikos://entity/{slug} if rest, ok := strings.CutPrefix(uri, "oikos://entity/"); ok && rest != "" { return map[string]string{"slug": rest} } // oikos://knowledge/{id} if rest, ok := strings.CutPrefix(uri, "oikos://knowledge/"); ok && rest != "" { return map[string]string{"id": rest} } // oikos://execution/{id} if rest, ok := strings.CutPrefix(uri, "oikos://execution/"); ok && rest != "" { return map[string]string{"id": rest} } return nil } // withActivityLogging wraps a tool handler to record agent_activity rows. func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next mcp.ToolHandler) mcp.ToolHandler { if agentID == uuid.Nil { return next } return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { start := time.Now() result, err := next(ctx, req) duration := int(time.Since(start).Milliseconds()) // Build input summary (first 500 chars of args) inputSummary := "" if req != nil && len(req.Params.Arguments) > 0 { inputSummary = string(req.Params.Arguments) } if len(inputSummary) > 500 { inputSummary = inputSummary[:500] } // Build output summary outputSummary := "" success := err == nil if result != nil { for _, c := range result.Content { if tc, ok := c.(*mcp.TextContent); ok { outputSummary = tc.Text break } } } if err != nil { outputSummary = err.Error() success = false } if len(outputSummary) > 500 { outputSummary = outputSummary[:500] } correlationID := uuid.New().String() entityID := resolveArgEntityID(ctx, pool, argsMap(req)) var entityIDArg any if entityID != uuid.Nil { entityIDArg = entityID } _, logErr := pool.Exec(ctx, ` INSERT INTO agent_activity (agent_id, activity_type, tool_name, entity_id, input_summary, output_summary, duration_ms, success, correlation_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, agentID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary, duration, success, correlationID) if logErr != nil { slog.Warn("mcp: log agent_activity", "error", logErr) } return result, err } } // entityArgKeys lists tool-argument keys, in priority order, that commonly // carry the target entity's slug or UUID. Tool input schemas aren't // consistent about naming this (target, entity_slug, slug, service_slug, // lxc_slug, entity_id all appear across server.go's tool registrations), so // this is a best-effort lookup used to tag agent_activity rows with the // entity a tool call acted on. var entityArgKeys = []string{ "target", "entity_slug", "slug", "slug_or_id", "service_slug", "lxc_slug", "entity_id", "about", } // resolveArgEntityID best-effort resolves the entity a tool call acted on // from its arguments, trying entityArgKeys in order. Returns uuid.Nil if no // key is present or none resolves to a known entity. func resolveArgEntityID(ctx context.Context, pool *db.Pool, args map[string]any) uuid.UUID { for _, key := range entityArgKeys { v, _ := args[key].(string) if v == "" { continue } if u, err := uuid.Parse(v); err == nil { return u } var id uuid.UUID if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", v).Scan(&id); err == nil { return id } } return uuid.Nil } // ─── Helpers ────────────────────────────────────────────────────────── func argsMap(req *mcp.CallToolRequest) map[string]any { if req == nil || len(req.Params.Arguments) == 0 { return nil } var m map[string]any json.Unmarshal(req.Params.Arguments, &m) return m } // slugArg returns the first non-empty string value among the given keys. // Tools historically used inconsistent param names for "the entity slug" // (slug_or_id, entity_id, service_slug, lxc_slug, target). This lets a single // handler accept any of them, so an agent guessing "slug" still works. func slugArg(m map[string]any, keys ...string) string { if m == nil { return "" } for _, k := range keys { if s, ok := m[k].(string); ok && strings.TrimSpace(s) != "" { return strings.TrimSpace(s) } } return "" } func getFloat(m map[string]any, key string, def float64) float64 { if m == nil { return def } switch v := m[key].(type) { case float64: return v case int: return float64(v) case json.Number: f, err := v.Float64() if err != nil { return def } return f } return def } func nStr(v any) any { if v == nil { return nil } s, _ := v.(string) if s == "" { return nil } return s } func textResult(s string) *mcp.CallToolResult { return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: s}}, } } // jsonOut builds a valid {"output": "..."} JSON payload for an execution's // result column. Command output contains quotes/backslashes/control chars, so // it must be JSON-marshaled — a hand-built string fails the ::jsonb cast and // silently drops the status update, leaving the execution stuck. func jsonOut(out string) []byte { b, _ := json.Marshal(map[string]any{"output": out}) return b } // jsonErr builds a valid {"error": "..."} JSON payload for an execution's // result column — same rationale as jsonOut, for the failure path. func jsonErr(format string, args ...any) []byte { b, _ := json.Marshal(map[string]any{"error": fmt.Sprintf(format, args...)}) return b } func queryEntity(ctx context.Context, pool *db.Pool, idOrSlug string) *mcp.CallToolResult { if strings.TrimSpace(idOrSlug) == "" { return textResult("slug_or_id is required — expected an entity slug (e.g. lxc:seanime) or UUID") } var id uuid.UUID if u, err := uuid.Parse(idOrSlug); err == nil { id = u } else { pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&id) } if id == uuid.Nil { return textResult(fmt.Sprintf("entity not found: %s", idOrSlug)) } return queryRows(ctx, pool, ` SELECT slug, type, name, state, attributes, maintenance_until::text, version, created_at, updated_at FROM entities WHERE id = $1`, id) } func queryRows(ctx context.Context, pool *db.Pool, query string, args ...any) *mcp.CallToolResult { rows, err := pool.Query(ctx, query, args...) if err != nil { return textResult(fmt.Sprintf("error: %v", err)) } defer rows.Close() cols := rows.FieldDescriptions() var items []map[string]any items = make([]map[string]any, 0) for rows.Next() { vals, err := rows.Values() if err != nil { continue } m := make(map[string]any) for i, col := range cols { m[string(col.Name)] = fmt.Sprintf("%v", vals[i]) } items = append(items, m) } if err := rows.Err(); err != nil { return textResult(fmt.Sprintf("error: %v", err)) } data, _ := json.MarshalIndent(items, "", " ") return textResult(string(data)) } func annotateJSONResult(result *mcp.CallToolResult, rendererID string) *mcp.CallToolResult { if len(result.Content) == 0 { return result } tc, ok := result.Content[0].(*mcp.TextContent) if !ok || tc.Text == "" { return result } var items []map[string]any if err := json.Unmarshal([]byte(tc.Text), &items); err != nil { return result } wrapper := map[string]any{ "__renderer": rendererID, "data": items, } data, _ := json.MarshalIndent(wrapper, "", " ") return textResult(string(data)) } // ─── SSH helpers ───────────────────────────────────────────────────────── var ( sshUser string sshKey []byte sshPool = make(map[string]*ssh.Client) sshPoolMu sync.Mutex ) func initSSH() { if sshUser == "" { sshUser = os.Getenv("OIKOS_SSH_USER") if sshUser == "" { sshUser = "root" } } keyPath := os.Getenv("OIKOS_SSH_KEY_PATH") if keyPath == "" { keyPath = "/etc/oikos/ssh_key" } if len(sshKey) == 0 { var err error sshKey, err = os.ReadFile(keyPath) if err != nil { slog.Warn("mcp ssh: cannot read key", "path", keyPath, "error", err) } } } // sshExecTimeout bounds how long a single remote command may run — see the // matching constant/comment in httpapi/phase3.go. Without it, a hung remote // command (piped install script stuck retrying DNS, etc.) blocks this // goroutine forever with no way for the caller to ever get an answer. const sshExecTimeout = 10 * time.Minute func sshExec(ctx context.Context, host, user, command string) (string, error) { return sshExecStream(ctx, host, user, command, nil) } // sshExecStream runs a command and reports its combined output, forwarding // each chunk to sink as it arrives. A nil sink behaves exactly as before. func sshExecStream(ctx context.Context, host, user, command string, sink execlog.Sink) (string, error) { initSSH() if len(sshKey) == 0 { return "", fmt.Errorf("no SSH key available") } if user == "" { user = sshUser } signer, err := actuator.LoadSignerFromBytes(sshKey) if err != nil { return "", fmt.Errorf("parse key: %w", err) } client, err := actuator.Dial(ctx, actuator.DialOptions{Host: host, User: user, Signer: signer}) if err != nil { return "", err } defer client.Close() return actuator.RunStreaming(ctx, client, command, sink, sshExecTimeout) } // resolveHost resolves a host: to its reachable IP and SSH user. A thin // wrapper over the shared resolver (internal/remote), kept so slug-based // callers keep working; the shared resolver also prefers public_ipv4 over // mesh and honors a per-entity ssh.user. func resolveHost(ctx context.Context, pool *db.Pool, entitySlug string) (hostIP string, sshUserOut string, err error) { return remote.ResolveHost(ctx, pool, entitySlug, sshUser) } // htmlTagRe strips HTML tags for the naive text extraction in httpGet. var htmlTagRe = regexp.MustCompile(`(?s)<(script|style)[^>]*>.*?|<[^>]+>`) // httpGet fetches a public URL and returns sanitized, size-capped text so the // agent can read a service's README/site before provisioning. Guards: scheme // allow-list, request timeout, 16KB body cap, and blocking of RFC1918/loopback // hosts to avoid using the tool as an SSRF pivot into the private mesh. func httpGet(ctx context.Context, rawURL string) *mcp.CallToolResult { if rawURL == "" { return textResult("error: url required") } u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return textResult("error: url must be an absolute http(s) URL") } if isPrivateHost(u.Hostname()) { return textResult("error: refusing to fetch private/loopback address") } cctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() hreq, err := http.NewRequestWithContext(cctx, http.MethodGet, u.String(), nil) if err != nil { return textResult(fmt.Sprintf("error: %v", err)) } hreq.Header.Set("User-Agent", "oikos-nomos/1.0 (+homelab agent)") hreq.Header.Set("Accept", "text/plain, text/html, application/json;q=0.9, */*;q=0.5") client := &http.Client{Timeout: 20 * time.Second} resp, err := client.Do(hreq) if err != nil { return textResult(fmt.Sprintf("error: fetch failed: %v", err)) } defer resp.Body.Close() const cap = 256 * 1024 // read a bit extra pre-strip; final output capped below body, _ := io.ReadAll(io.LimitReader(resp.Body, cap)) ct := resp.Header.Get("Content-Type") text := sanitizeBody(ct, string(body)) return textResult(fmt.Sprintf("GET %s → %d %s\n\n%s", u.String(), resp.StatusCode, ct, text)) } // sanitizeBody strips scripts/styles/tags from HTML, unescapes entities, // collapses whitespace, and caps the result to ~16KB of readable text. func sanitizeBody(contentType, raw string) string { text := raw if strings.Contains(contentType, "html") { text = htmlTagRe.ReplaceAllString(text, " ") text = html.UnescapeString(text) text = strings.Join(strings.Fields(text), " ") } if len(text) > 16*1024 { text = text[:16*1024] + "\n…[truncated]" } return text } // isPrivateHost reports whether host is loopback, link-local, or RFC1918. func isPrivateHost(host string) bool { host = strings.ToLower(host) if host == "localhost" || strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") { return true } ip := net.ParseIP(host) if ip == nil { return false // hostname; DNS may still resolve private — acceptable for a homelab tool } return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() } // resolveExecTarget resolves any target slug (host:, lxc:, or vm:) to the SSH // endpoint that will actually run the command, and a wrap function that turns // a plain shell command into whatever must actually be sent over that SSH // connection: identity for a host, `pct exec -- ...` for an LXC, // `qm guest exec -- ...` for a VM. // // Delegates to the shared resolver (internal/remote), the single path used by // both the MCP `run` tool and the scheduler's checks. The historical notes // (host attr without prefix, vm host-resolution chain, nested-quoting // handling via base64) all still hold — they now live in remote.guestWrap. func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (host, user string, wrap func(cmd string) string, err error) { et, err := remote.ResolveExecTarget(ctx, pool, targetSlug, sshUser) if err != nil { return "", "", nil, err } return et.Host, et.User, et.Wrap, nil } // resolveProxmoxHostSlug resolves the Proxmox host slug that owns a given // LXC/VM target (see internal/remote.ResolveProxmoxHostSlug for the chain). // This slug-based wrapper looks up the entity id so slug callers keep working; // the shared resolver takes an id directly. func resolveProxmoxHostSlug(ctx context.Context, pool *db.Pool, entitySlug, hostAttr string) string { var id uuid.UUID if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", entitySlug).Scan(&id); err != nil { id = uuid.Nil } return remote.ResolveProxmoxHostSlug(ctx, pool, id, hostAttr) } // classifyAndGate is the shared classify→execute-or-queue path for every // mutating command, used by both the general `run` tool and docker_exec. // Since Phase 4 of the hexagonal refactor this is a thin rendering shell: // the decision pipeline lives in app.PolicyService, the recording + // dispatch in app.ExecutionService; this maps the result onto the // agent-facing text. func classifyAndGate(ctx context.Context, pool *db.Pool, execSvc *app.ExecutionService, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk, sessionID string) *mcp.CallToolResult { res := execSvc.Submit(ctx, app.ExecutionSubmitCmd{ AgentID: domain.UUID(agentID.String()), TargetID: domain.UUID(targetID.String()), TargetSlug: targetSlug, Command: command, Purpose: purpose, DeclaredRisk: declaredRisk, SessionID: sessionID, Async: app.IsLongRunningCommand(command), SinkFactory: func(ctx context.Context, execID domain.UUID, correlationID string) (func(string, []byte), func()) { return execlog.New(ctx, pool, uuid.MustParse(string(execID)), correlationID) }, }) return renderSubmit(targetSlug, command, res) } // renderSubmit maps an ExecutionSubmitResult onto the agent-facing text, // preserving the exact pre-service message shapes. func renderSubmit(targetSlug, command string, res app.ExecutionSubmitResult) *mcp.CallToolResult { d := res.Decision switch d.Action { case app.DecisionRefuse: return textResult(d.Message) case app.DecisionQueue: confirmNote := "" if d.RiskClass == policy.RiskDestructive { confirmNote = " This is classified DESTRUCTIVE — flag that clearly to the operator; it needs explicit confirmation, not just a casual \"go ahead\"." } return textResult(fmt.Sprintf("run on %s requires approval (risk: %s) — execution %s queued.%s Present the command and purpose to the operator and wait; do not re-request.", targetSlug, d.RiskClass, res.ExecutionID, confirmNote)) case app.DecisionAuto: label := d.RiskClass switch d.AutoViaWindow { case "assent": label = "config_mutation" case "destructive": label = "destructive" } if res.AsyncStarted { via := "" switch d.AutoViaWindow { case "assent": via = ", async via assent window" case "destructive": via = ", async via confirmed-target window" default: via = ", async" } return textResult(fmt.Sprintf("run on %s (%s%s): started — execution %s. Poll with get_execution_status(%s) for result.", targetSlug, label, via, res.ExecutionID, res.ExecutionID)) } if res.Err != nil { return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, res.Err, res.Output)) } via := ", auto" switch d.AutoViaWindow { case "assent": via = ", auto via assent window" case "destructive": via = ", auto via confirmed-target window" } return textResult(fmt.Sprintf("run on %s (%s%s): %s", targetSlug, label, via, res.Output)) } return textResult(fmt.Sprintf("run on %s: unknown decision %q", targetSlug, d.Action)) } // autoApprove updates the approval + execution status in the DB to approved, // mirroring what DecideApproval does. Returns true on success. This is used // by the assent-window path to skip the operator-approval queue when the // operator already approved the overall plan via chat assent. // executeApprovedViaAPI calls the HTTP API's approval-decision endpoint to // trigger the actual execution. The API server (phase3.executeApprovedAction) // handles the real SSH work (pct create, apt upgrade, etc.) in a goroutine. // We POST to the decision endpoint to reuse the exact same execution path // as a manual Approve-button click, ensuring the audit trail is consistent. func executeApprovedViaAPI(ctx context.Context, execID uuid.UUID, targetSlug, actionStr string) { apiBase := os.Getenv("OIKOS_API_BASE") if apiBase == "" { apiBase = "http://api:8090" } body, _ := json.Marshal(map[string]string{"decision": "approve"}) client := &http.Client{Timeout: 10 * time.Second} req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+"/api/v1/approvals/"+execID.String()+"/decision", bytes.NewReader(body)) if err != nil { slog.Error("mcp: executeApprovedViaAPI request", "error", err) return } req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { slog.Error("mcp: executeApprovedViaAPI call", "error", err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { // A non-200 here means the real SSH work was never dispatched — this // is the call that actually triggers executeApprovedAction via // DecideApproval. (A previous version of this comment claimed a // non-200 was fine because a since-removed "autoApprove" step had // already triggered execution via a raw DB update — it hadn't; that // was the bug where auto-approved pct_create/apt_upgrade never // actually ran. There is no other path that dispatches the work.) slog.Error("mcp: executeApprovedViaAPI non-200 — execution was NOT dispatched", "status", resp.StatusCode, "execution", execID) } } // knowledgeSlugRe strips a title down to a slug segment. var knowledgeSlugRe = regexp.MustCompile(`[^a-z0-9]+`) func knowledgeSlug(kind, title string) string { s := strings.ToLower(strings.TrimSpace(title)) s = knowledgeSlugRe.ReplaceAllString(s, "-") s = strings.Trim(s, "-") if s == "" { s = "note" } if len(s) > 80 { s = s[:80] } return kind + ":nomos/" + s } // upsertKnowledge is the agent's write-back path — the missing half of the // knowledge loop (search_knowledge/get_entity_knowledge could only read). // Without this, everything the agent learned lived only in an ephemeral chat // message and was lost; the system could never actually "get better." A // knowledge doc IS an entity (type document/investigation/runbook) with a row // in knowledge_entities; re-titling the same thing updates in place rather // than duplicating. Optionally linked to the entity it's about so // get_entity_knowledge surfaces it there. func upsertKnowledge(ctx context.Context, pool *db.Pool, args map[string]any) (*mcp.CallToolResult, error) { title, _ := args["title"].(string) content, _ := args["content"].(string) tagsRaw, _ := args["tags"].(string) kind, _ := args["kind"].(string) // Normalize about: accept a single string slug or an array of slugs. var aboutSlugs []string switch v := args["about"].(type) { case string: if s := strings.TrimSpace(v); s != "" { aboutSlugs = []string{s} } case []interface{}: for _, item := range v { if s, ok := item.(string); ok { if s = strings.TrimSpace(s); s != "" { aboutSlugs = append(aboutSlugs, s) } } } } title = strings.TrimSpace(title) content = strings.TrimSpace(content) if title == "" || content == "" { return textResult("error: title and content are required"), nil } switch kind { case "document", "investigation", "runbook": case "": kind = "investigation" default: return textResult(fmt.Sprintf("error: kind must be document, investigation, or runbook (got %q)", kind)), nil } var tags []string for _, t := range strings.Split(tagsRaw, ",") { if t = strings.TrimSpace(t); t != "" { tags = append(tags, t) } } slug := knowledgeSlug(kind, title) // Upsert the knowledge-doc entity, getting its id whether it already // existed or we just created it. docID, _ := uuid.NewV7() err := pool.QueryRow(ctx, ` INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, $3, $4, '{}') ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now() RETURNING id`, docID, slug, kind, title).Scan(&docID) if err != nil { return textResult(fmt.Sprintf("error creating knowledge entity: %v", err)), nil } // Upsert the knowledge content (search column is generated, don't set it). _, err = pool.Exec(ctx, ` INSERT INTO knowledge_entities (entity_id, title, content, source, tags, updated_at) VALUES ($1, $2, $3, 'nomos-agent', $4, now()) ON CONFLICT (entity_id) DO UPDATE SET title = EXCLUDED.title, content = EXCLUDED.content, tags = EXCLUDED.tags, updated_at = now()`, docID, title, content, tags) if err != nil { return textResult(fmt.Sprintf("error writing knowledge: %v", err)), nil } // Link it to the entity(s) it's about, if given and not already linked. linked := "" if len(aboutSlugs) > 0 { var linkedSlugs []string for _, slug := range aboutSlugs { var targetID uuid.UUID if qerr := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&targetID); qerr == nil { pool.Exec(ctx, ` INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) SELECT $1, $2, 'documents', '{"by":"nomos"}'::jsonb, now() WHERE NOT EXISTS ( SELECT 1 FROM relationships WHERE source_id = $1 AND target_id = $2 AND type = 'documents' AND valid_to IS NULL)`, docID, targetID) linkedSlugs = append(linkedSlugs, slug) } } if len(linkedSlugs) == 1 { linked = " and linked to " + linkedSlugs[0] } else if len(linkedSlugs) > 1 { linked = fmt.Sprintf(" and linked to %d entities", len(linkedSlugs)) } } _ = observability.Event(ctx, sqlcgen.New(pool), "knowledge.upserted", &docID, "info", "mcp", "", map[string]any{"slug": slug, "title": title, "kind": kind}) return textResult(fmt.Sprintf("Saved knowledge %q as %s%s. It's now searchable via search_knowledge and will surface in future sessions.", title, slug, linked)), nil } // inspectPathAcrossTargets is the bulk fact-gathering helper behind the // inspect_path MCP tool (plans/2026-07-18-session-review-three-sessions.md // P1.5). For each target slug, it runs a single read-only shell command // producing mount/df/ls/stat output for the given path, and returns the // results as a map keyed by target slug. // // Why this exists: sessions 1e9c7691 and 55927f0a each spent ~15 `run` // calls gathering identical facts (`mount | grep`, `df`, `ls -la`, `stat`) // across hosts and LXCs to trace where a path lives, who mounts it, and // what permissions it has. One call here replaces that fan-out. All // commands are read-only — the tool bypasses classifyAndGate and runs // directly via sshExec against resolveExecTarget's host/wrap. Failures // (unresolvable target, SSH error) are reported per-target in the result // map, not as a single tool-level error, so one bad target doesn't lose // the others. // // The per-target command is intentionally compact: one combined shell // invocation that prints mount source/dest, df, ls -la of the path's // parent + the path itself, and stat. Output is truncated to 4KB per // target to keep the total result reasonable for an 8-target call. func inspectPathAcrossTargets(ctx context.Context, pool *db.Pool, path string, targets []string) map[string]any { results := make(map[string]any, len(targets)) path = strings.TrimSpace(path) var wg sync.WaitGroup var mu sync.Mutex wg.Add(len(targets)) for _, tgt := range targets { go func(target string) { defer wg.Done() entry := inspectOneTarget(ctx, pool, path, target) mu.Lock() results[target] = entry mu.Unlock() }(tgt) } wg.Wait() return results } // inspectOneTarget runs the read-only inspection for one target. Returns a // map with keys: "ok" (bool), "output" (string, on success), "error" // (string, on failure). Kept small so the JSON shape is stable across the // parallel-call path. func inspectOneTarget(ctx context.Context, pool *db.Pool, path, target string) map[string]any { host, user, wrap, rerr := resolveExecTarget(ctx, pool, target) if rerr != nil { return map[string]any{"ok": false, "error": fmt.Sprintf("resolve target: %v", rerr)} } // One shell invocation, four sections, each guarded by `2>&1 || true` // so a missing path doesn't kill the rest. Stat with -c gives a // stable machine-readable line for ownership/perms; ls -la gives the // human-readable listing of the path and its parent (so we can see // both "what's in here" and "how the parent is laid out" — useful for // NFS-root-vs-subdir permission mismatches, the exact issue in // session 1e9c7691). cmd := fmt.Sprintf( `echo "=== mount ==="; mount 2>/dev/null | grep -- "%[1]s" || echo "(not a mount point)"; echo "=== df ==="; df -h "%[1]s" 2>&1 || true; echo "=== stat ==="; stat -c '%%a %%U:%%G (size=%%s, type=%%F)' "%[1]s" 2>&1 || true; echo "=== ls -la path ==="; ls -la "%[1]s" 2>&1 | head -40 || true; echo "=== ls -la parent ==="; ls -la "$(dirname "%[1]s")" 2>&1 | head -20 || true`, path) out, xerr := sshExec(ctx, host, user, wrap(cmd)) if xerr != nil { return map[string]any{"ok": false, "error": fmt.Sprintf("ssh: %v: %s", xerr, out)} } // Truncate per-target output to keep an 8-target call's total under // ~32KB. 4KB per target is enough for the head -40/head -20 listings // above; if a directory is enormous, the truncation keeps the result // usable without flooding the model's context. const maxPerTarget = 4096 if len(out) > maxPerTarget { out = out[:maxPerTarget] + fmt.Sprintf("\n...truncated (%d bytes total)", len(out)) } return map[string]any{"ok": true, "output": out} }