feat: structured __renderer envelopes for MCP tools
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:
@@ -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 ──────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user