mcp: add 5 operational tools — tail_log, get_service_status, ping_service, list_lxcs, get_lxc_state
This commit is contained in:
@@ -61,6 +61,8 @@ services:
|
|||||||
OIKOS_ENV: dev
|
OIKOS_ENV: dev
|
||||||
OIKOS_DEBUG: "true"
|
OIKOS_DEBUG: "true"
|
||||||
OIKOS_HERMES_AGENT_SLUG: ${OIKOS_HERMES_AGENT_SLUG:-agent:hermes}
|
OIKOS_HERMES_AGENT_SLUG: ${OIKOS_HERMES_AGENT_SLUG:-agent:hermes}
|
||||||
|
volumes:
|
||||||
|
- ${OIKOS_SSH_KEY_PATH:-~/.ssh/id_ed25519}:/etc/oikos/ssh_key:ro
|
||||||
ports:
|
ports:
|
||||||
- "8090:8090"
|
- "8090:8090"
|
||||||
command: ["api"]
|
command: ["api"]
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/db"
|
||||||
"github.com/google/jsonschema-go/jsonschema"
|
"github.com/google/jsonschema-go/jsonschema"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// prop is one input-schema property (name → type + description).
|
// prop is one input-schema property (name → type + description).
|
||||||
@@ -323,6 +327,147 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
ORDER BY ts DESC LIMIT $2`, agentID, limit), nil
|
ORDER BY ts DESC LIMIT $2`, agentID, limit), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── Phase 5: operational MCP tools ──────────────────────────────
|
||||||
|
|
||||||
|
register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, and state",
|
||||||
|
InputSchema: objSchema(),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
|
||||||
|
e.attributes->>'lan_ip' AS lan_ip,
|
||||||
|
st.health, st.last_check_at
|
||||||
|
FROM entities e
|
||||||
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
|
WHERE e.type = 'lxc'
|
||||||
|
ORDER BY (e.attributes->>'pve_id')::int`), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP",
|
||||||
|
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}),
|
||||||
|
}, 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
|
||||||
|
}
|
||||||
|
rows, err := pool.Query(ctx, `
|
||||||
|
SELECT st.health, st.last_check_at, e.attributes->>'url' 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
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
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 == "" {
|
||||||
|
url = "(no URL in entity attributes)"
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("health=%s last_check=%s url=%s", health, lastCheck, url)), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
register(&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)"}),
|
||||||
|
}, 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
|
||||||
|
})
|
||||||
|
|
||||||
|
register(&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)"}),
|
||||||
|
}, 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
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
register(&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)"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["lxc_slug"].(string)
|
||||||
|
if slug == "" {
|
||||||
|
return textResult("lxc_slug is required"), nil
|
||||||
|
}
|
||||||
|
var pveID string
|
||||||
|
err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", slug).Scan(&pveID)
|
||||||
|
if err != nil || pveID == "" {
|
||||||
|
return textResult(fmt.Sprintf("LXC not found or missing pve_id: %s", slug)), nil
|
||||||
|
}
|
||||||
|
// Resolve the Proxmox host — find the host that runs this LXC
|
||||||
|
var hostID uuid.UUID
|
||||||
|
err = pool.QueryRow(ctx, `
|
||||||
|
SELECT t.id FROM entities t
|
||||||
|
JOIN relationships r ON r.source_id = t.id
|
||||||
|
JOIN entities s ON s.id = r.target_id
|
||||||
|
WHERE s.slug = $1 AND r.type = 'hosts' AND r.valid_to IS NULL
|
||||||
|
LIMIT 1`, slug).Scan(&hostID)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback: use the inventory host attribute if no relationship
|
||||||
|
var hostSlug string
|
||||||
|
err = pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", slug).Scan(&hostSlug)
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("resolve host: %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 s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,3 +616,100 @@ func queryRows(ctx context.Context, pool *db.Pool, query string, args ...any) *m
|
|||||||
data, _ := json.MarshalIndent(items, "", " ")
|
data, _ := json.MarshalIndent(items, "", " ")
|
||||||
return textResult(string(data))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sshExec(ctx context.Context, host, user, command string) (string, error) {
|
||||||
|
initSSH()
|
||||||
|
if len(sshKey) == 0 {
|
||||||
|
return "", fmt.Errorf("no SSH key available")
|
||||||
|
}
|
||||||
|
if user == "" {
|
||||||
|
user = sshUser
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := host + ":22"
|
||||||
|
signer, err := ssh.ParsePrivateKey(sshKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &ssh.ClientConfig{
|
||||||
|
User: user,
|
||||||
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := ssh.Dial("tcp", addr, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("dial %s: %w", host, err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
out, err := session.CombinedOutput(command)
|
||||||
|
if err != nil && out == nil {
|
||||||
|
return "", fmt.Errorf("exec: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveHost(ctx context.Context, pool *db.Pool, entitySlug string) (hostIP string, sshUser string, err error) {
|
||||||
|
var attrs string
|
||||||
|
err = pool.QueryRow(ctx, "SELECT attributes::text FROM entities WHERE slug = $1", entitySlug).Scan(&attrs)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("entity not found: %s", entitySlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
var m map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(attrs), &m); err != nil {
|
||||||
|
return "", "", fmt.Errorf("parse attributes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip, ok := m["lan_ip"].(string); ok && ip != "" {
|
||||||
|
return ip, sshUser, nil
|
||||||
|
}
|
||||||
|
if mesh, ok := m["mesh"].(map[string]interface{}); ok {
|
||||||
|
for _, proto := range []string{"netbird", "tailscale"} {
|
||||||
|
if p, ok := mesh[proto].(map[string]interface{}); ok {
|
||||||
|
if ip, ok := p["ip"].(string); ok && ip != "" {
|
||||||
|
return ip, sshUser, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf("no IP found for %s", entitySlug)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user