Files
oikos/internal/mcp/server.go
dtoro b98d7c24bf
Some checks are pending
ci / build-test (push) Waiting to run
ci / docker-build (push) Waiting to run
feat: Phase 9 gaps closed — ApprovalService.Decide convergence, execlog fold, execworker poller
- ApprovalService (core/app/approval.go) + ApprovalRepo (postgres adapter) with
  full decide transaction: HMAC token verify, approval flip, execution un-gate,
  session-scoped window keys (+session suffix matching GovernanceStore gate),
  nomos session flip, audit+event on failure abort. httpapi DecideApproval now
  a thin presenter delegating to the service. ListPending payload format fixed
  (json.Unmarshal not raw-wrap).
- execlog folded into postgres adapter: internal/execlog deleted, NewExecutionLog
  / ReadExecutionLog live in the db package, callers updated (mcp, httpapi).
- execworker poller over ExecutionService.DispatchQueued: advisory lock leak
  fixed (defer/recover per execution), correlation_id preserved via Finalize
  event emission (ExecRunRepo.Finalize now emits execution.{status} with
  correlation_id from the row).
- Phase 8 session export-rename completed: Store, New, and all 53 methods
  exported; cmd/nomos/ agent.go fixed to use session.PendingContinuation etc.
- Coverage gates: ExecutionService.Submit 93.1%, PolicyService.Decide 100%.
- Plans index updated, VERSION bumped to 0.36.0.
2026-08-16 12:29:59 +02:00

921 lines
33 KiB
Go

// 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/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"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/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 db.ExecLogSink) (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:<slug> 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)[^>]*>.*?</(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 <pve_id> -- ...` for an LXC,
// `qm guest exec <pve_id> -- ...` 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 db.NewExecutionLog(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}
}