Files
fleet/cmd/fleet-mcp/mcp_tools_hosts.go
Lucas Manuel Rodriguez 56763d13c1 Move fleet-mcp from tools/ to cmd/ (#49044)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** #43544. 

Moves `fleet-mcp` from `tools/fleet-mcp/` to `cmd/fleet-mcp/`. It is
becoming a production server used by customers, so it now lives under
`cmd/` alongside the other Fleet binaries.

Per the module strategy chosen for this move, it **remains a standalone
Go module** (keeps its own `go.mod`/`go.sum` and isolated deps such as
`mark3labs/mcp-go`, `logrus`, `gorilla/websocket`, `godotenv`) — the
root `github.com/fleetdm/fleet/v4` module is unchanged.

### What changed
- `git mv tools/fleet-mcp/ → cmd/fleet-mcp/` (history preserved as
renames).
- Updated all path references:
  - Root `Makefile` `update-go` module list.
- `.github/workflows/test-fleet-mcp.yml` — trigger paths,
`go-version-file`, `working-directory`.
  - `.github/dependabot.yml` — gomod directory.
  - `cmd/fleet-mcp/render.yaml` — `rootDir`.
- `cmd/fleet-mcp/README.md`, `Makefile`, `schema.go` — path
comments/links.
  - `articles/fleet-mcp.md` — README link.
  - Removed the `fleet-mcp/` row from `tools/README.md`.

### Follow-up (not in this PR)
- The Render service's Blueprint file path must be updated from
`tools/fleet-mcp/render.yaml` to `cmd/fleet-mcp/render.yaml` in the
Render dashboard.

## Testing
- `go build .` in `cmd/fleet-mcp` — OK
- `go test -race -count=1 ./...` — `ok fleet-mcp`

- [x] QA'd all new/changed functionality manually
2026-07-09 13:29:09 -03:00

326 lines
16 KiB
Go

package main
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/sirupsen/logrus"
)
func registerHostTools(s *server.MCPServer, fleetClient *FleetClient) {
registerGetEndpoints(s, fleetClient)
registerGetHost(s, fleetClient)
registerGetHostPolicies(s, fleetClient)
registerGetTotalSystemCount(s, fleetClient)
registerGetAggregatePlatforms(s, fleetClient)
registerGetFleets(s, fleetClient)
registerGetLabels(s, fleetClient)
}
func registerGetEndpoints(s *server.MCPServer, fleetClient *FleetClient) {
tool := mcp.NewTool("get_endpoints",
mcp.WithDescription("Get a list of hosts/endpoints enrolled in Fleet with full server-side filtering. All filters compose: combine fleet+platform+label+policy_id+policy_response+status+query in one call to narrow precisely instead of paginating client-side. The `query` parameter alone covers user / IP / hostname / serial / hardware model / IdP group as a case-insensitive substring — reach for it before paginating. Use get_host for full details on one host, get_host_policies for one host's compliance, get_policy_hosts for hosts grouped by policy result. Do NOT call this tool repeatedly with per_page=1 just to count — use get_total_system_count instead."),
mcp.WithString("fleet", mcp.Description("Optional fleet name to filter by (e.g. 'Workstations')")),
mcp.WithString("platform", mcp.Description("Optional platform to filter by (e.g. 'macos', 'windows', 'linux')")),
mcp.WithString("status", mcp.Description("Optional host status filter (e.g. 'online', 'offline', 'new', 'mia')")),
mcp.WithString("query", mcp.Description("Optional substring (case-insensitive) matched against hostname, hardware serial, primary IP, hardware model, AND user inventory (username / email / IdP group). Best way to narrow results when you have a partial identifier such as a person's name, email, or IP fragment.")),
mcp.WithString("label", mcp.Description("Optional Fleet label name (e.g. 'macOS', 'engineering'). Resolved to a label_id server-side. Use get_labels to discover names.")),
mcp.WithString("policy_id", mcp.Description("Optional numeric policy ID (from get_policies) to scope to hosts that have a result on that policy.")),
mcp.WithString("policy_response", mcp.Description("Optional 'passing' or 'failing'. Requires policy_id — narrows to hosts that pass / fail that specific policy.")),
mcp.WithString("per_page", mcp.Description("Max number of hosts to return (default 50, max 200)")),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(true),
)
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
logrus.Info("Tool invoked: get_endpoints")
fleet := getOptionalString(request, "fleet")
platform := getOptionalString(request, "platform")
status := getOptionalString(request, "status")
query := getOptionalString(request, "query")
label := getOptionalString(request, "label")
policyID := getOptionalString(request, "policy_id")
policyResponse := getOptionalString(request, "policy_response")
// Validate at the MCP layer so the AI client gets a clear message
// rather than a cryptic Fleet 400.
if policyResponse != "" && policyID == "" {
return mcp.NewToolResultError("policy_response is only valid when policy_id is also set"), nil
}
if policyResponse != "" && policyResponse != "passing" && policyResponse != "failing" {
return mcp.NewToolResultError(fmt.Sprintf("policy_response must be 'passing' or 'failing', got %q", policyResponse)), nil
}
perPage := parsePerPageArg(request, defaultEndpointsPerPage)
anyFilter := fleet != "" || platform != "" || status != "" || query != "" || label != "" || policyID != "" || policyResponse != ""
var endpoints []Endpoint
var err error
if anyFilter {
endpoints, err = fleetClient.GetEndpointsWithFilters(ctx, fleet, platform, status, query, label, policyID, policyResponse, perPage)
} else {
endpoints, err = fleetClient.GetEndpoints(ctx, perPage)
}
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoints: %v", err)), nil
}
// Total must describe the filter scope, not the global inventory —
// otherwise a filtered listing reports a misleading count.
var totalCount int
if anyFilter {
totalCount, err = fleetClient.GetHostCountWithFilters(ctx, fleet, platform, status, query, label, policyID, policyResponse)
} else {
totalCount, err = fleetClient.GetHostCount(ctx)
}
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to get host count: %v", err)), nil
}
return jsonResult(struct {
Total int `json:"total"`
Returned int `json:"returned"`
Endpoints []Endpoint `json:"endpoints"`
}{
Total: totalCount,
Returned: len(endpoints),
Endpoints: endpoints,
})
})
}
func registerGetHost(s *server.MCPServer, fleetClient *FleetClient) {
tool := mcp.NewTool("get_host",
mcp.WithDescription("Get full details for a single host including its labels, fleet, and platform info. Accepts a numeric `host_id` (most precise), or an `identifier` (exact hostname / UUID / hardware serial, OR a substring to fuzzy-match). If the substring matches exactly one host, full details are returned. If multiple match — for example two hosts share a hostname — a candidate list is returned with each host's id, hostname, display_name, hardware_serial, primary_ip, and team so you can pick the right one and re-call with `host_id`.\n\nIMPORTANT: substring matching covers hostname / serial / IP / model / user inventory but NOT display_name. If the host you want has only a custom display_name (a user-set computer name that does not appear in any indexed string field), use `host_id` from a candidate list. Use get_endpoints when you need many hosts; use get_host_policies when you need a host's policy compliance."),
mcp.WithString("host_id", mcp.Description("Numeric Fleet host ID (e.g. '1309'). Unambiguous. Use whenever you have it — preferred over identifier when collisions are possible.")),
mcp.WithString("identifier", mcp.Description("Optional. Exact hostname / UUID / serial OR a fuzzy substring (e.g. 'jsmith' → 'jsmiths-macbook-pro.local'). Required if host_id is not set. Does NOT match display_name.")),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(true),
)
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
logrus.Info("Tool invoked: get_host")
hostIDArg := getOptionalString(request, "host_id")
identifier := getOptionalString(request, "identifier")
if hostIDArg == "" && identifier == "" {
return mcp.NewToolResultError("either host_id or identifier is required"), nil
}
// Case 1: explicit numeric host_id wins. Always exact.
if hostIDArg != "" {
id, err := parseHostIDArg(hostIDArg)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
host, err := fleetClient.GetHostByID(ctx, id)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to get host by id: %v", err)), nil
}
return jsonResult(host)
}
// Case 2: identifier path — query-first to detect collisions before
// falling back to /hosts/identifier/:id (which silently picks one
// when multiple hosts share a hostname). Cap at 50: Fleet's substring
// matcher is permissive so we need room for collisions to surface.
const maxCandidates = 50
candidates, qErr := fleetClient.GetEndpointsWithFilters(ctx, "", "", "", identifier, "", "", "", maxCandidates)
if qErr == nil && len(candidates) == 1 {
// Single unambiguous match — fetch by ID for guaranteed
// no-collision lookup.
full, fErr := fleetClient.GetHostByID(ctx, candidates[0].ID)
if fErr == nil {
return jsonResult(full)
}
// API hiccup on the ID lookup — degrade to the candidate hit.
return jsonResult(candidates[0])
}
if qErr == nil && len(candidates) > 1 {
return jsonResult(map[string]interface{}{
"message": fmt.Sprintf("%d hosts match %q. Note that the substring search does NOT cover display_name; pick the `id` from the candidates below and re-call with `host_id` set.", len(candidates), identifier),
"candidates": candidates,
})
}
// Zero query matches OR query failed: fall back to identifier endpoint
// (catches UUIDs and other identifiers Fleet's substring index misses).
host, err := fleetClient.GetHostByIdentifier(ctx, identifier)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Host not found by query or identifier: %s (substring search does NOT cover display_name — try host_id if you have it)", identifier)), nil
}
return jsonResult(host)
})
}
func registerGetTotalSystemCount(s *server.MCPServer, fleetClient *FleetClient) {
tool := mcp.NewTool("get_total_system_count",
mcp.WithDescription("Get the total count of active systems enrolled in Fleet"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(true),
)
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
logrus.Info("Tool invoked: get_total_system_count")
count, err := fleetClient.GetHostCount(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to get total count: %v", err)), nil
}
return mcp.NewToolResultText(fmt.Sprintf("Total Enrolled Systems: %d", count)), nil
})
}
func registerGetAggregatePlatforms(s *server.MCPServer, fleetClient *FleetClient) {
tool := mcp.NewTool("get_aggregate_platforms",
mcp.WithDescription("Get the count of systems aggregated by platform (macOS, Windows, Linux)"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(true),
)
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
logrus.Info("Tool invoked: get_aggregate_platforms")
aggregate, err := fleetClient.GetEndpointsWithAggregations(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to get aggregate platforms: %v", err)), nil
}
dataMap, ok := aggregate.Data.(map[string]interface{})
if !ok {
return mcp.NewToolResultError("Unexpected data format returned from Fleet"), nil
}
platformBreakdown, ok := dataMap["platform_breakdown"]
if !ok {
return mcp.NewToolResultError("platform_breakdown key missing from Fleet response"), nil
}
return jsonResult(platformBreakdown)
})
}
func registerGetFleets(s *server.MCPServer, fleetClient *FleetClient) {
tool := mcp.NewTool("get_fleets",
mcp.WithDescription("Get all fleets with their IDs and names. Use this to discover the exact fleet names before filtering by fleet in other tools."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(true),
)
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
logrus.Info("Tool invoked: get_fleets")
fleets, err := fleetClient.GetTeams(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to get fleets: %v", err)), nil
}
return jsonResult(fleets)
})
}
func registerGetLabels(s *server.MCPServer, fleetClient *FleetClient) {
tool := mcp.NewTool("get_labels",
mcp.WithDescription("Get a list of all labels in Fleet"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(true),
)
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
logrus.Info("Tool invoked: get_labels")
labels, err := fleetClient.GetLabels(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to get labels: %v", err)), nil
}
return jsonResult(labels)
})
}
func registerGetHostPolicies(s *server.MCPServer, fleetClient *FleetClient) {
tool := mcp.NewTool("get_host_policies",
mcp.WithDescription("Get the compliance status of every policy applied to a single host (global + fleet-inherited). Each policy entry has a `response` field: 'pass', 'fail', or '' (not yet run). The response also includes a summary block with `failing_count`, `passing_count`, `not_run_count`, and `total` so you can answer 'is this host compliant?' directly. Use this — NOT get_host — whenever the question is 'which policies is this host failing?', 'is host X compliant?', or 'what policies apply to this host?'. get_host returns base host info without policy data; this tool wraps a single API call (populate_policies=true) that includes them. Optionally filter by `response` ('passing'|'failing') to narrow the returned list.\n\nIDENTIFIER GUIDANCE: pass `host_id` (numeric) when known — it is unambiguous. `identifier` accepts an exact hostname / UUID / serial OR a substring; substring matching covers hostname / serial / primary IP / hardware model / user inventory but NOT display_name. When multiple hosts match the substring (e.g. shared hostname), this tool returns a candidate list with each host's id, hostname, display_name, serial, primary_ip, and team — re-call with `host_id` from the candidate you want."),
mcp.WithString("host_id", mcp.Description("Numeric Fleet host ID (e.g. '1309'). When set, takes precedence over identifier and bypasses substring matching. Use this whenever you have a concrete ID from a candidate list or prior call.")),
mcp.WithString("identifier", mcp.Description("Optional. Exact hostname / UUID / hardware serial, OR a substring matched against hostname / serial / IP / model / user inventory. Required if host_id is not set. Note: does NOT match display_name — use host_id for display-name-only hosts.")),
mcp.WithString("response", mcp.Description("Optional filter: 'passing' (only `response=='pass'` entries) or 'failing' (only `response=='fail'` entries). Defaults to all.")),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(true),
)
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
logrus.Info("Tool invoked: get_host_policies")
hostIDArg := getOptionalString(request, "host_id")
identifier := getOptionalString(request, "identifier")
responseFilter := getOptionalString(request, "response")
hostID, err := parseHostIDArg(hostIDArg)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
if hostID == 0 && identifier == "" {
return mcp.NewToolResultError("either host_id or identifier is required"), nil
}
if responseFilter != "" && responseFilter != "passing" && responseFilter != "failing" {
return mcp.NewToolResultError(fmt.Sprintf("response must be 'passing' or 'failing', got %q", responseFilter)), nil
}
host, ambiguous, candidates, err := resolveHostDetail(ctx, fleetClient, hostID, identifier, fleetClient.GetHostByIDWithPolicies, fleetClient.GetHostByIdentifierWithPolicies)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to get host policies: %v", err)), nil
}
if ambiguous {
return jsonResult(map[string]interface{}{
"message": fmt.Sprintf("%d hosts match %q. Note that the substring search does NOT cover display_name; if the host you want shares a hostname with others, pick its `id` from the candidates below and re-call this tool with `host_id` set.", len(candidates), identifier),
"candidates": candidates,
})
}
// Compute summary counts — this mirrors the "This device is failing N
// policies" headline shown in the Fleet UI per-host Policies tab and
// gives the AI client a direct compliance answer without scanning.
var failing, passing, notRun int
for _, p := range host.Policies {
switch p.Response {
case "fail":
failing++
case "pass":
passing++
default:
notRun++
}
}
// Apply optional response filter to the policies list.
filtered := host.Policies
if responseFilter != "" {
want := "pass"
if responseFilter == "failing" {
want = "fail"
}
filtered = make([]HostPolicyEntry, 0, len(host.Policies))
for _, p := range host.Policies {
if p.Response == want {
filtered = append(filtered, p)
}
}
}
return jsonResult(struct {
Host Endpoint `json:"host"`
Summary map[string]int `json:"summary"`
Policies []HostPolicyEntry `json:"policies"`
}{
Host: host.Endpoint,
Summary: map[string]int{
"failing_count": failing,
"passing_count": passing,
"not_run_count": notRun,
"total": len(host.Policies),
},
Policies: filtered,
})
})
}