This repository has been archived on 2026-03-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
homelab-deploy/internal/mcp/tools.go
Torjus Håkestad 713d1e7584 chore: migrate module path from git.t-juice.club to code.t-juice.club
Gitea to Forgejo host migration — update Go module path and all
import references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:37:47 +01:00

209 lines
6.2 KiB
Go

// Package mcp provides an MCP server for AI assistants.
package mcp
import (
"context"
"fmt"
"strings"
"time"
"github.com/mark3labs/mcp-go/mcp"
deploycli "code.t-juice.club/torjus/homelab-deploy/internal/cli"
"code.t-juice.club/torjus/homelab-deploy/internal/messages"
)
// ToolConfig holds configuration for the MCP tools.
type ToolConfig struct {
NATSUrl string
NKeyFile string
AdminNKeyFile string
DiscoverSubject string
Timeout time.Duration
}
// DeployTool creates the test-tier deploy tool definition.
func DeployTool() mcp.Tool {
return mcp.NewTool(
"deploy",
mcp.WithDescription("Deploy NixOS configuration to test-tier hosts"),
mcp.WithString("hostname",
mcp.Description("Target hostname, or omit to use 'all' or 'role' targeting"),
),
mcp.WithBoolean("all",
mcp.Description("Deploy to all test-tier hosts"),
),
mcp.WithString("role",
mcp.Description("Deploy to all test-tier hosts with this role"),
),
mcp.WithString("branch",
mcp.Description("Git branch or commit to deploy (default: master)"),
),
mcp.WithString("action",
mcp.Description("nixos-rebuild action: switch, boot, test, dry-activate (default: switch)"),
mcp.Enum("switch", "boot", "test", "dry-activate"),
),
)
}
// DeployAdminTool creates the admin deploy tool definition (all tiers).
func DeployAdminTool() mcp.Tool {
return mcp.NewTool(
"deploy_admin",
mcp.WithDescription("Deploy NixOS configuration to any host (admin access required)"),
mcp.WithString("tier",
mcp.Required(),
mcp.Description("Target tier: test or prod"),
mcp.Enum("test", "prod"),
),
mcp.WithString("hostname",
mcp.Description("Target hostname, or omit to use 'all' or 'role' targeting"),
),
mcp.WithBoolean("all",
mcp.Description("Deploy to all hosts in tier"),
),
mcp.WithString("role",
mcp.Description("Deploy to all hosts with this role in tier"),
),
mcp.WithString("branch",
mcp.Description("Git branch or commit to deploy (default: master)"),
),
mcp.WithString("action",
mcp.Description("nixos-rebuild action: switch, boot, test, dry-activate (default: switch)"),
mcp.Enum("switch", "boot", "test", "dry-activate"),
),
)
}
// ListHostsTool creates the list_hosts tool definition.
func ListHostsTool() mcp.Tool {
return mcp.NewTool(
"list_hosts",
mcp.WithDescription("List available deployment targets"),
mcp.WithString("tier",
mcp.Description("Filter by tier: test or prod (optional)"),
mcp.Enum("test", "prod"),
),
)
}
// ToolHandler handles tool calls.
type ToolHandler struct {
cfg ToolConfig
}
// NewToolHandler creates a new tool handler.
func NewToolHandler(cfg ToolConfig) *ToolHandler {
return &ToolHandler{cfg: cfg}
}
// HandleDeploy handles the deploy tool (test-tier only).
func (h *ToolHandler) HandleDeploy(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return h.handleDeployWithTier(ctx, request, "test", h.cfg.NKeyFile)
}
// HandleDeployAdmin handles the deploy_admin tool (any tier).
func (h *ToolHandler) HandleDeployAdmin(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
tier, err := request.RequireString("tier")
if err != nil {
return mcp.NewToolResultError("tier is required"), nil
}
if tier != "test" && tier != "prod" {
return mcp.NewToolResultError("tier must be 'test' or 'prod'"), nil
}
return h.handleDeployWithTier(ctx, request, tier, h.cfg.AdminNKeyFile)
}
func (h *ToolHandler) handleDeployWithTier(ctx context.Context, request mcp.CallToolRequest, tier, nkeyFile string) (*mcp.CallToolResult, error) {
// Build subject based on targeting
hostname := request.GetString("hostname", "")
all := request.GetBool("all", false)
role := request.GetString("role", "")
var subject string
if hostname != "" {
subject = fmt.Sprintf("deploy.%s.%s", tier, hostname)
} else if all {
subject = fmt.Sprintf("deploy.%s.all", tier)
} else if role != "" {
subject = fmt.Sprintf("deploy.%s.role.%s", tier, role)
} else {
return mcp.NewToolResultError("must specify hostname, all, or role"), nil
}
// Parse action
actionStr := request.GetString("action", "switch")
action := messages.Action(actionStr)
if !action.Valid() {
return mcp.NewToolResultError(fmt.Sprintf("invalid action: %s", actionStr)), nil
}
// Parse branch
branch := request.GetString("branch", "master")
cfg := deploycli.DeployConfig{
NATSUrl: h.cfg.NATSUrl,
NKeyFile: nkeyFile,
Subject: subject,
Action: action,
Revision: branch,
Timeout: h.cfg.Timeout,
}
var output strings.Builder
output.WriteString(fmt.Sprintf("Deploying to %s (action=%s, revision=%s)\n\n", subject, action, branch))
result, err := deploycli.Deploy(ctx, cfg, func(resp *messages.DeployResponse) {
status := string(resp.Status)
if resp.Error != nil {
status = fmt.Sprintf("%s (%s)", status, *resp.Error)
}
output.WriteString(fmt.Sprintf("[%s] %s: %s\n", resp.Hostname, status, resp.Message))
})
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("deployment failed: %v", err)), nil
}
output.WriteString(fmt.Sprintf("\nDeployment complete: %d hosts responded\n", result.HostCount()))
if !result.AllSucceeded() {
output.WriteString("WARNING: Some deployments failed\n")
}
return mcp.NewToolResultText(output.String()), nil
}
// HandleListHosts handles the list_hosts tool.
func (h *ToolHandler) HandleListHosts(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
tierFilter := request.GetString("tier", "")
responses, err := deploycli.Discover(ctx, h.cfg.NATSUrl, h.cfg.NKeyFile, h.cfg.DiscoverSubject, 5*time.Second)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("discovery failed: %v", err)), nil
}
if len(responses) == 0 {
return mcp.NewToolResultText("No hosts responded to discovery request"), nil
}
var output strings.Builder
output.WriteString("Available deployment targets:\n\n")
for _, resp := range responses {
if tierFilter != "" && resp.Tier != tierFilter {
continue
}
role := resp.Role
if role == "" {
role = "(none)"
}
output.WriteString(fmt.Sprintf("- %s (tier=%s, role=%s)\n", resp.Hostname, resp.Tier, role))
output.WriteString(fmt.Sprintf(" Subjects: %s\n", strings.Join(resp.DeploySubjects, ", ")))
}
return mcp.NewToolResultText(output.String()), nil
}