feat: structured __renderer envelopes for MCP tools
Some checks are pending
ci / build-test (push) Waiting to run
ci / docker-build (push) Waiting to run

All 19 oikos tools now return JSON envelopes with __renderer hints
(terminal, service_status, ping, path_report, etc.) carrying structured
data alongside the model-facing prose in a `message` field — agent
behavior is unchanged while the UI renders native cards.

Fixes: ping_service SQL (json||text precedence), timestamptz scan,
run:{...} action prefix parsing, get_execution_status target slug join.

Also: deploy.sh step 5.5 restarts dsh web after API deploy; watchdog
LaunchAgent detects API recovery and kickstarts dsh web for stale MCP
sessions.
This commit is contained in:
2026-08-17 16:49:56 +02:00
parent 7712e957ed
commit e12954323c
7 changed files with 520 additions and 156 deletions

View File

@@ -19,6 +19,23 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// lxcStateEnvelope runs pct status on the Proxmox host and wraps the output
// in a terminal envelope labelled with the LXC slug.
func lxcStateEnvelope(ctx context.Context, host, user, slug, pveID string) *mcp.CallToolResult {
cmd := fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID)
out, err := sshExec(ctx, host, user, cmd)
if err != nil {
return textResult(fmt.Sprintf("ssh: %v", err))
}
return rendererEnvelope("terminal", terminalEnvelope{
Target: slug,
Command: cmd,
State: "done",
Output: out,
Message: fmt.Sprintf("Resource state of %s from its Proxmox host.", slug),
})
}
func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.ExecutionService) []toolReg {
return []toolReg{
// ── request_execution (legacy fixed enum) retired 2026-07-14 ──
@@ -137,80 +154,133 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
targets = append(targets, s)
}
}
results := inspectPathAcrossTargets(ctx, pool, path, targets)
out, _ := json.MarshalIndent(results, "", " ")
return textResult(string(out)), nil
results := inspectPathAcrossTargets(ctx, pool, path, targets)
return rendererEnvelope("path_report", map[string]any{
"path": path,
"targets": results,
}), nil
}},
{tool: &mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
InputSchema: objSchema(
prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
execID, _ := args["execution_id"].(string)
if execID == "" {
return textResult("execution_id required"), nil
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
execID, _ := args["execution_id"].(string)
if execID == "" {
return textResult("execution_id required"), nil
}
eid, err := uuid.Parse(execID)
if err != nil {
// Try finding by exec slug prefix
var found uuid.UUID
err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found)
if err2 != nil {
return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
}
eid, err := uuid.Parse(execID)
if err != nil {
// Try finding by exec slug prefix
var found uuid.UUID
err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found)
if err2 != nil {
return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
}
eid = found
}
return queryRows(ctx, pool, `
SELECT e.entity_id::text, e.action, e.risk_class, e.status,
e.result::text, e.duration_ms, e.started_at::text,
e.completed_at::text, e.correlation_id
FROM executions e
WHERE e.entity_id = $1`, eid), nil
}},
eid = found
}
rows, err := pool.Query(ctx, `
SELECT e.entity_id::text, e.action, e.risk_class, e.status,
e.result::text, e.duration_ms, e.started_at::text,
e.completed_at::text, e.correlation_id,
COALESCE(t.slug, '')
FROM executions e
LEFT JOIN entities t ON t.id = e.target_entity_id
WHERE e.entity_id = $1`, eid)
if err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
defer rows.Close()
if !rows.Next() {
return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
}
var entityID, action, riskClass, status, result, started, completed, correlation, targetSlug string
var duration any
if err := rows.Scan(&entityID, &action, &riskClass, &status, &result, &duration, &started, &completed, &correlation, &targetSlug); err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
env := executionTerminalEnvelope(action, riskClass, status, result)
env.Target = targetSlug
env.ExecID = entityID
if s, ok := duration.(int64); ok {
env.Message += fmt.Sprintf(" Duration: %dms.", s)
}
return rendererEnvelope("terminal", env), nil
}},
{tool: &mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl",
InputSchema: objSchema(
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"},
prop{"lines", "integer", "Number of lines (default 50)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["service_slug"].(string)
n := int(getFloat(args, "lines", 50))
if slug == "" {
return textResult("service_slug is required"), nil
}
host, user, err := resolveHost(ctx, pool, slug)
if err != nil {
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
}
svc := strings.TrimPrefix(slug, "lxc:")
out, err := sshExec(ctx, host, user, fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n))
if err != nil {
return textResult(fmt.Sprintf("ssh: %v", err)), nil
}
return textResult(out), nil
}},
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["service_slug"].(string)
n := int(getFloat(args, "lines", 50))
if slug == "" {
return textResult("service_slug is required"), nil
}
host, user, err := resolveHost(ctx, pool, slug)
if err != nil {
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
}
svc := strings.TrimPrefix(slug, "lxc:")
cmd := fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n)
out, err := sshExec(ctx, host, user, cmd)
if err != nil {
return textResult(fmt.Sprintf("ssh: %v", err)), nil
}
return rendererEnvelope("terminal", terminalEnvelope{
Target: slug,
Command: cmd,
State: "done",
Output: out,
Message: fmt.Sprintf("Last %d log lines of %s.", n, slug),
}), nil
}},
{tool: &mcp.Tool{Name: "get_service_status", Description: "Check systemd service status on a host",
InputSchema: objSchema(
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["service_slug"].(string)
if slug == "" {
return textResult("service_slug is required"), nil
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["service_slug"].(string)
if slug == "" {
return textResult("service_slug is required"), nil
}
host, user, err := resolveHost(ctx, pool, slug)
if err != nil {
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
}
svc := strings.TrimPrefix(slug, "lxc:")
out, err := sshExec(ctx, host, user,
fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc))
if err != nil {
return textResult(fmt.Sprintf("ssh: %v", err)), nil
}
// systemctl output: line 1 is-active, line 2 is-enabled, then
// Key=Value pairs from show. "could not be found" on either of
// the first lines means the unit does not exist.
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
active, enabled := lines[0], ""
if len(lines) > 1 {
enabled = lines[1]
}
status := map[string]string{
"service": slug,
"active": active,
"enabled": enabled,
}
for _, l := range lines[2:] {
if k, v, ok := strings.Cut(l, "="); ok {
switch k {
case "ActiveEnterTimestamp":
status["since"] = v
case "SubState":
status["sub_state"] = v
}
}
host, user, err := resolveHost(ctx, pool, slug)
if err != nil {
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
}
svc := strings.TrimPrefix(slug, "lxc:")
out, err := sshExec(ctx, host, user,
fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc))
if err != nil {
return textResult(fmt.Sprintf("ssh: %v", err)), nil
}
return textResult(out), nil
}},
}
return rendererEnvelope("service_status", status), nil
}},
{tool: &mcp.Tool{Name: "get_lxc_state", Description: "Get LXC container resource state from Proxmox host",
InputSchema: objSchema(
prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:caddy)"}),
@@ -240,29 +310,21 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
if err != nil || hostSlug == "" {
return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", slug)), nil
}
var host, user string
host, user, err = resolveHost(ctx, pool, "host:"+hostSlug)
if err != nil {
return textResult(fmt.Sprintf("resolve: %v", err)), nil
}
out, err2 := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
if err2 != nil {
return textResult(fmt.Sprintf("ssh: %v", err2)), nil
}
return textResult(out), nil
}
var hostSlug string
pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug)
host, user, err := resolveHost(ctx, pool, hostSlug)
var host, user string
host, user, err = resolveHost(ctx, pool, "host:"+hostSlug)
if err != nil {
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
return textResult(fmt.Sprintf("resolve: %v", err)), nil
}
out, err := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
if err != nil {
return textResult(fmt.Sprintf("ssh: %v", err)), nil
}
return textResult(out), nil
}},
return lxcStateEnvelope(ctx, host, user, slug, pveID), nil
}
var hostSlug string
pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug)
host, user, err := resolveHost(ctx, pool, hostSlug)
if err != nil {
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
}
return lxcStateEnvelope(ctx, host, user, slug, pveID), nil
}},
{tool: &mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP — returns scheduler health state plus a live HTTP probe",
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -271,17 +333,17 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
if slug == "" {
return textResult("service_slug is required"), nil
}
rows, err := pool.Query(ctx, `
SELECT st.health, st.last_check_at,
COALESCE(
e.attributes->>'url',
CASE WHEN e.attributes->>'public_host' IS NOT NULL
THEN 'https://' || e.attributes->>'public_host'
END
) AS url
FROM entity_status st
JOIN entities e ON e.id = st.entity_id
WHERE e.slug = $1`, slug)
rows, err := pool.Query(ctx, `
SELECT st.health, st.last_check_at::text,
COALESCE(
e.attributes->>'url',
CASE WHEN e.attributes->>'public_host' IS NOT NULL
THEN 'https://' || (e.attributes->>'public_host')
END
) AS url
FROM entity_status st
JOIN entities e ON e.id = st.entity_id
WHERE e.slug = $1`, slug)
if err != nil {
return textResult(fmt.Sprintf("query error: %v", err)), nil
}
@@ -289,20 +351,29 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
if !rows.Next() {
return textResult(fmt.Sprintf("service not found: %s", slug)), nil
}
var health, lastCheck, url string
rows.Scan(&health, &lastCheck, &url)
if url == "" {
return textResult(fmt.Sprintf("health=%s last_check=%s url=no-url (entity has no url or public_host attribute)", health, lastCheck)), nil
var health, lastCheck, url string
if err := rows.Scan(&health, &lastCheck, &url); err != nil {
return textResult(fmt.Sprintf("scan error: %v", err)), nil
}
// Live HTTP probe — HEAD request to check current state
code := "n/a"
if resp, err := http.Head(url); err == nil {
resp.Body.Close()
code = fmt.Sprintf("%d", resp.StatusCode)
} else {
code = fmt.Sprintf("err: %v", err)
}
return textResult(fmt.Sprintf("health=%s last_check=%s url=%s http=%s", health, lastCheck, url, code)), nil
if url == "" {
return rendererEnvelope("ping", map[string]string{
"service": slug, "health": health, "last_check": lastCheck,
"url": "", "http": "",
"note": "entity has no url or public_host attribute",
}), nil
}
// Live HTTP probe — HEAD request to check current state
code := ""
if resp, err := http.Head(url); err == nil {
resp.Body.Close()
code = fmt.Sprintf("%d", resp.StatusCode)
} else {
code = fmt.Sprintf("err: %v", err)
}
return rendererEnvelope("ping", map[string]string{
"service": slug, "health": health, "last_check": lastCheck,
"url": url, "http": code,
}), nil
}},
// ─── Phase 5: operational MCP tools ──────────────────────────────

View File

@@ -450,12 +450,86 @@ func annotateJSONResult(result *mcp.CallToolResult, rendererID string) *mcp.Call
if err := json.Unmarshal([]byte(tc.Text), &items); err != nil {
return result
}
wrapper := map[string]any{
return rendererEnvelope(rendererID, items)
}
// rendererEnvelope wraps any JSON-serializable payload in the __renderer
// envelope oikos-ui dispatches on. data may be a row list (array) or a single
// structured object; callers that only have prose keep textResult.
func rendererEnvelope(rendererID string, data any) *mcp.CallToolResult {
payload, err := json.MarshalIndent(map[string]any{
"__renderer": rendererID,
"data": items,
"data": data,
}, "", " ")
if err != nil {
return textResult(fmt.Sprintf("%v", data))
}
data, _ := json.MarshalIndent(wrapper, "", " ")
return textResult(string(data))
return textResult(string(payload))
}
// terminalEnvelope is the structured shape of a command-execution result:
// everything a terminal card needs (target, command, combined output, state)
// plus the model-facing prose in Message, so restructuring the result for the
// UI never changes what the agent reads.
type terminalEnvelope struct {
Target string `json:"target"`
Command string `json:"command"`
State string `json:"state"` // queued | started | done | error | refused
Risk string `json:"risk,omitempty"`
ExecID string `json:"exec_id,omitempty"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
NeedsApproval bool `json:"needs_approval,omitempty"`
Message string `json:"message"`
}
// executionTerminalEnvelope builds the terminal envelope for a stored
// execution row: command/purpose from the action JSON, output/error from the
// result JSON, state from the execution status.
func executionTerminalEnvelope(action, riskClass, status, result string) terminalEnvelope {
env := terminalEnvelope{
Command: action,
Risk: riskClass,
State: "started",
Message: "",
}
var act struct {
Command string `json:"command"`
Purpose string `json:"purpose"`
}
// The stored action prefixes the JSON payload with the tool kind
// (`run:{...}`); parse from the first '{'.
if i := strings.Index(action, "{"); i >= 0 {
if err := json.Unmarshal([]byte(action[i:]), &act); err == nil && act.Command != "" {
env.Command = act.Command
if act.Purpose != "" {
env.Message = "Purpose: " + act.Purpose + "\n"
}
}
}
switch status {
case "completed":
env.State = "done"
case "failed":
env.State = "error"
case "cancelled":
env.State = "refused"
}
var res struct {
Output string `json:"output"`
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(result), &res); err == nil {
env.Output = res.Output
env.Error = res.Error
} else if result != "" {
env.Output = result
}
env.Message += fmt.Sprintf("Execution %s: %s.", status, status)
if env.Error != "" {
env.Message += " Error: " + env.Error
}
return env
}
// ─── SSH helpers ─────────────────────────────────────────────────────────
@@ -650,24 +724,41 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, execSvc *app.ExecutionS
return db.NewExecutionLog(ctx, pool, uuid.MustParse(string(execID)), correlationID)
},
})
return renderSubmit(targetSlug, command, res)
return renderSubmit(targetSlug, command, purpose, 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 {
// renderSubmit maps an ExecutionSubmitResult onto the agent-facing result: a
// terminal envelope whose Message keeps the exact pre-service prose (the
// agent's operating instructions live there), with the structured fields the
// terminal card renders.
func renderSubmit(targetSlug, command, purpose string, res app.ExecutionSubmitResult) *mcp.CallToolResult {
d := res.Decision
env := terminalEnvelope{
Target: targetSlug,
Command: command,
State: "unknown",
Message: "",
}
if purpose != "" {
env.Message = "Purpose: " + purpose + "\n"
}
switch d.Action {
case app.DecisionRefuse:
return textResult(d.Message)
env.State = "refused"
env.Error = d.Message
env.Message += 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))
env.State = "queued"
env.Risk = string(d.RiskClass)
env.ExecID = string(res.ExecutionID)
env.NeedsApproval = true
env.Message += 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
@@ -677,6 +768,8 @@ func renderSubmit(targetSlug, command string, res app.ExecutionSubmitResult) *mc
case "destructive":
label = "destructive"
}
env.Risk = string(label)
env.ExecID = string(res.ExecutionID)
if res.AsyncStarted {
via := ""
switch d.AutoViaWindow {
@@ -687,11 +780,17 @@ func renderSubmit(targetSlug, command string, res app.ExecutionSubmitResult) *mc
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))
env.State = "started"
env.Message += 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)
break
}
if res.Err != nil {
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, res.Err, res.Output))
env.State = "error"
env.Error = res.Err.Error()
env.Output = res.Output
env.Message += fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, res.Err, res.Output)
break
}
via := ", auto"
switch d.AutoViaWindow {
@@ -700,9 +799,14 @@ func renderSubmit(targetSlug, command string, res app.ExecutionSubmitResult) *mc
case "destructive":
via = ", auto via confirmed-target window"
}
return textResult(fmt.Sprintf("run on %s (%s%s): %s", targetSlug, label, via, res.Output))
env.State = "done"
env.Output = res.Output
env.Message += fmt.Sprintf("run on %s (%s%s): %s", targetSlug, label, via, res.Output)
default:
env.Message += fmt.Sprintf("run on %s: unknown decision %q", targetSlug, d.Action)
}
return textResult(fmt.Sprintf("run on %s: unknown decision %q", targetSlug, d.Action))
return rendererEnvelope("terminal", env)
}
// autoApprove updates the approval + execution status in the DB to approved,