Files
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

253 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/mark3labs/mcp-go/mcp"
)
// - if hostID is present, fetch the host directly
// - otherwise it searches by identifier query-first, falling back to the identifier endpoint for identifiers the
// substring search misses (e.g. UUIDs).
//
// When the identifier matches more than one host (Fleet allows duplicate
// hostnames), it returns ambiguous=true with the matching candidates and a nil
// host, so the caller can surface the list and re-call with a specific host_id
// rather than silently acting on the wrong host.
func resolveHostDetail[T any](
ctx context.Context,
fleetClient *FleetClient,
hostID uint,
identifier string,
byID func(context.Context, uint) (*T, error),
byIdentifier func(context.Context, string) (*T, error),
) (host *T, ambiguous bool, candidates []Endpoint, err error) {
if hostID != 0 {
h, hErr := byID(ctx, hostID)
if hErr != nil {
return nil, false, nil, hErr
}
return h, false, nil, nil
}
const maxCandidates = 50
cands, qErr := fleetClient.GetEndpointsWithFilters(ctx, "", "", "", identifier, "", "", "", maxCandidates)
if qErr == nil && len(cands) == 1 {
h, hErr := byID(ctx, cands[0].ID)
if hErr != nil {
return nil, false, nil, hErr
}
return h, false, nil, nil
}
if qErr == nil && len(cands) > 1 {
return nil, true, cands, nil
}
h, idErr := byIdentifier(ctx, identifier)
if idErr != nil {
return nil, false, nil, fmt.Errorf("host not found by query or identifier: %s (substring search does NOT cover display_name — try host_id if you have it)", identifier)
}
return h, false, nil, nil
}
func parseHostIDArg(hostIDArg string) (uint, error) {
if hostIDArg == "" {
return 0, nil
}
id, err := strconv.ParseUint(hostIDArg, 10, strconv.IntSize)
if err != nil || id == 0 {
return 0, fmt.Errorf("host_id must be a positive integer, got %q", hostIDArg)
}
return uint(id), nil
}
// getOptionalString reads an optional string argument from an MCP tool request.
// Returns empty string if the argument is absent, non-string, or the request has
// no arguments map.
func getOptionalString(req mcp.CallToolRequest, key string) string {
args, ok := req.Params.Arguments.(map[string]interface{})
if !ok {
return ""
}
v, _ := args[key].(string)
return v
}
// parseCSVArg reads an optional comma-separated string argument and returns
// each non-empty segment trimmed of surrounding whitespace. Empty segments
// (e.g. "foo,,bar" or " , , ") are dropped — otherwise they propagate as
// zero-value strings into downstream filter logic, and a leading empty
// segment can silently disable filters that pull only `parts[0]`. Returns
// nil when the argument is absent, empty, or contained only whitespace/commas.
func parseCSVArg(req mcp.CallToolRequest, key string) []string {
raw := getOptionalString(req, key)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
t := strings.TrimSpace(p)
if t == "" {
continue
}
out = append(out, t)
}
if len(out) == 0 {
return nil
}
return out
}
// jsonResult marshals v as indented JSON and wraps it in an MCP text tool
// result. On marshal failure, returns an MCP error result rather than a Go
// error so the client receives a structured failure (matching the existing
// handler convention).
func jsonResult(v interface{}) (*mcp.CallToolResult, error) {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to format output: %v", err)), nil
}
return mcp.NewToolResultText(string(b)), nil
}
// defaultPerPageMax is the documented hard cap on the `per_page` MCP arg
// across host-listing tools. Callers that ask for more are silently clamped
// to keep result sets bounded — Fleet inventories of 50k hosts × ~2KB per
// Endpoint = ~100MB which would OOM the MCP if we let any caller fetch the
// world.
const defaultPerPageMax = 200
// parsePerPageArg reads the `per_page` MCP arg and returns a clamped value:
// - missing or unparseable → fallback (intended to be the handler's default).
// - n <= 0 → fallback (negative or zero is a misuse; treat as default).
// - n > defaultPerPageMax → clamped to defaultPerPageMax.
//
// Centralized so every host-listing tool enforces the same contract that
// the tool descriptions advertise ("default 50, max 200").
func parsePerPageArg(req mcp.CallToolRequest, fallback int) int {
raw := getOptionalString(req, "per_page")
if raw == "" {
return fallback
}
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n <= 0 {
return fallback
}
if n > defaultPerPageMax {
return defaultPerPageMax
}
return n
}
// cveIDPattern matches the canonical CVE-YYYY-NNNN[N…] identifier shape
// (CVE prefix, four-digit year, dash, four-or-more-digit sequence). Anchored
// so that only the full-string form is accepted. Used by validateCVEID.
var cveIDPattern = regexp.MustCompile(`^CVE-\d{4}-\d{4,}$`)
// validateCVEID rejects CVE identifiers that don't match the canonical shape
// before we send them to Fleet. Stops malformed inputs from becoming opaque
// Fleet API errors and prevents weird inputs (URL injection attempts,
// unicode, etc.) from reaching the upstream.
func validateCVEID(cveID string) error {
cveID = strings.TrimSpace(cveID)
if cveID == "" {
return fmt.Errorf("cve_id is required (expected shape CVE-YYYY-NNNN, e.g. CVE-2025-12345)")
}
if !cveIDPattern.MatchString(cveID) {
return fmt.Errorf("cve_id %q is not a valid CVE identifier (expected shape CVE-YYYY-NNNN, e.g. CVE-2025-12345)", cveID)
}
return nil
}
// parsePositiveUintString parses a string like "42" as a positive integer.
// Used to validate numeric path-segment params (policy_id, team_id) before
// they're interpolated into Fleet API URLs. Returns an error naming the
// field so the AI client gets a usable hint about what failed.
func parsePositiveUintString(field, val string) (uint64, error) {
val = strings.TrimSpace(val)
if val == "" {
return 0, fmt.Errorf("%s is required", field)
}
n, err := strconv.ParseUint(val, 10, 64)
if err != nil || n == 0 {
return 0, fmt.Errorf("%s must be a positive integer (got %q)", field, val)
}
return n, nil
}
// parseCSVUintArg reads an optional comma-separated list of unsigned integers.
// Returns nil when absent / empty. Returns an error naming the bad token if
// any segment is not a positive integer — surfaced verbatim to the caller.
func parseCSVUintArg(req mcp.CallToolRequest, key string) ([]uint, error) {
raw := getOptionalString(req, key)
if strings.TrimSpace(raw) == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
out := make([]uint, 0, len(parts))
for _, p := range parts {
t := strings.TrimSpace(p)
if t == "" {
continue
}
n, err := strconv.ParseUint(t, 10, strconv.IntSize)
if err != nil || n == 0 {
return nil, fmt.Errorf("%s: %q is not a positive integer", key, t)
}
out = append(out, uint(n))
}
return out, nil
}
// buildLiveQuerySpecFromRequest reads every supported targeting argument off
// an MCP request and returns a LiveQueryTargetSpec ready to hand to
// FleetClient.ResolveLiveQueryTargets. Centralizes argument parsing so the
// preview path (prepare_live_query) and the execution path (run_live_query)
// stay byte-for-byte aligned on what the user actually targeted.
func buildLiveQuerySpecFromRequest(req mcp.CallToolRequest) (LiveQueryTargetSpec, error) {
hostIDs, err := parseCSVUintArg(req, "host_ids")
if err != nil {
return LiveQueryTargetSpec{}, err
}
policyResp := getOptionalString(req, "policy_response")
policyID := getOptionalString(req, "policy_id")
if policyResp != "" && policyID == "" {
return LiveQueryTargetSpec{}, fmt.Errorf("policy_response is only valid when policy_id is also set")
}
if policyResp != "" && policyResp != "passing" && policyResp != "failing" {
return LiveQueryTargetSpec{}, fmt.Errorf("policy_response must be 'passing' or 'failing', got %q", policyResp)
}
if policyID != "" {
if _, err := parsePositiveUintString("policy_id", policyID); err != nil {
return LiveQueryTargetSpec{}, err
}
}
cveID := getOptionalString(req, "cve_id")
if cveID != "" {
if err := validateCVEID(cveID); err != nil {
return LiveQueryTargetSpec{}, err
}
}
return LiveQueryTargetSpec{
Fleet: getOptionalString(req, "fleet"),
Platform: getOptionalString(req, "platform"),
Label: getOptionalString(req, "label"),
Status: getOptionalString(req, "status"),
Query: getOptionalString(req, "query"),
PolicyID: policyID,
PolicyResponse: policyResp,
CVEID: cveID,
Hostnames: parseCSVArg(req, "hostnames"),
HostIDs: hostIDs,
LegacyFleets: parseCSVArg(req, "fleets"),
LegacyPlatforms: parseCSVArg(req, "platforms"),
LegacyLabels: parseCSVArg(req, "labels"),
}, nil
}