// Package mcp provides an MCP server for AI assistants. package mcp import ( "context" "fmt" "strings" "time" "github.com/mark3labs/mcp-go/mcp" deploycli "git.t-juice.club/torjus/homelab-deploy/internal/cli" "git.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 }