feat(fleet-mcp): add inventory tools (get_software, get_host_users) (#48092)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #47931, Relates to #43544 This PR brings the fleet-mcp inventory tools into Fleet: `get_software` (per-host and cross-host) and `get_host_users`. These were introduced in @karmine05 's repo but were not present in Fleet: - https://github.com/karmine05/fleet-mcp/commit/64d60b7fcdd75e3722d77d269845edc8826b88c6 - https://github.com/karmine05/fleet-mcp/commit/fb753e61e77d65d3b54466ec69e33306e0d6e065 Added on top: a `platform` requires `fleet` guard (Fleet's `/software/titles` rejects `platform` without `team_id`, found via live testing), unit tests for the arg guards and resolvers. # Checklist for submitter - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually `get_host_users` calls: <img width="631" height="882" alt="get_hosts_1" src="https://github.com/user-attachments/assets/36999f93-3276-49c6-9c25-83f67c4b288a" /> <img width="742" height="769" alt="get_hosts_2" src="https://github.com/user-attachments/assets/822b415d-7d61-43b8-a310-a6a6cd22dc27" /> <img width="700" height="892" alt="get_hosts_3" src="https://github.com/user-attachments/assets/d2fde072-bb93-466b-9c1b-07df7e5eb137" /> `get_software` <img width="837" height="1346" alt="Screenshot 2026-06-24 at 11 48 08 AM" src="https://github.com/user-attachments/assets/8c50dade-9d66-4b05-a733-d70a6633e1ba" /> `get_host_policies` (re-tested after the refactoring on this PR) <img width="1432" height="920" alt="Screenshot 2026-06-24 at 11 49 37 AM" src="https://github.com/user-attachments/assets/22e91dff-0ded-4d9e-9488-b6e0f605b5a2" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added software inventory lookup with filtering by source, platform, and vulnerability status. * Added host user data retrieval with optional filtering. * Implemented pagination support for large inventory result sets. * Added host resolution and ambiguity detection for user queries. * Truncation indicators for capped result sets. * **Tests** * Comprehensive integration test coverage for inventory lookups and pagination behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -23,7 +23,7 @@ Both **SSE** (Server-Sent Events) and **stdio** transports are supported. The sa
|
||||
|
||||
## Tools
|
||||
|
||||
The server exposes tools across three domains: **hosts**, **queries**, and **policies/vulnerabilities**. One of them (`run_live_query`) runs arbitrary osquery on devices, so scope the Fleet API token accordingly (see [Security model](#security-model)).
|
||||
The server exposes tools across four domains: **hosts**, **queries**, **policies/vulnerabilities**, and **inventory**. One of them (`run_live_query`) runs arbitrary osquery on devices, so scope the Fleet API token accordingly (see [Security model](#security-model)).
|
||||
|
||||
### Hosts
|
||||
|
||||
@@ -58,6 +58,15 @@ The server exposes tools across three domains: **hosts**, **queries**, and **pol
|
||||
| `get_vulnerability_impact` | Aggregate count of systems impacted by a CVE |
|
||||
| `get_vulnerability_hosts` | List the specific hosts impacted by a CVE, optionally narrowed by `fleet`, `platform`, `label`, `status`, `query`. Composes a 3-step lookup (`/software/titles?vulnerable=true&query=CVE` → vulnerable version IDs → `/hosts?software_version_id=N`) and intersects client-side. Required because Fleet's `/hosts?cve=` and `/hosts?platform=` filters are silently ignored — see the Operational learnings section. |
|
||||
|
||||
### Inventory
|
||||
|
||||
These read from Fleet's stored host inventory (refreshed on each host check-in), so they answer "what's installed / who has an account" **without** a live osquery query — they work even for currently-offline hosts.
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `get_software` | List software/packages from Fleet's stored inventory. Two modes, auto-selected: **per-host** (pass `host_id` or `host_identifier`) returns every package on that host with version / source / installed paths / matching CVEs via `/hosts/:id/software`; **cross-host** (no host arg) returns software TITLES seen across hosts via `/software/titles` — the full inventory by default, optionally scoped by `fleet` / `vulnerable` (and `platform`, which requires `fleet`: Fleet's titles endpoint only filters by platform together with a team). The `source` arg (e.g. `npm_packages`, `python_packages`, `apps`, `deb_packages`, `chrome_extensions`) is a client-side case-insensitive filter against the osquery source table name. Use `query` for a substring match on software name or a CVE id. Prefer this over `run_live_query` for inventory lookups — cached, always-available, no host CPU. |
|
||||
| `get_host_users` | List OS-local user accounts on a single host as inventoried by osquery (uid, username, type, groupname, shell). Accepts `host_id` (preferred) or `host_identifier` (same disambiguation as `get_host`). Optional `query` substring filters the returned users client-side across username / uid / groupname / shell. |
|
||||
|
||||
### Filter dimensions at a glance
|
||||
|
||||
| Dimension | How to filter | Notes |
|
||||
@@ -305,6 +314,7 @@ tools/fleet-mcp/
|
||||
mcp_tools_hosts.go # host-domain MCP tools
|
||||
mcp_tools_queries.go # query-domain MCP tools
|
||||
mcp_tools_policies.go # policy/vuln MCP tools
|
||||
mcp_tools_inventory.go # inventory MCP tools
|
||||
schema.go # canonical osquery schema (embedded fallback + live HTTP refresh from raw.githubusercontent.com/fleetdm/fleet/main/schema/osquery_fleet_schema.json) and ValidateSQLForPlatforms (table-vs-platform + TEXT-column type sniff)
|
||||
osquery_fleet_schema.json # vendored canonical snapshot (//go:embed source-of-truth fallback). Refresh via `go generate ./tools/fleet-mcp/...`.
|
||||
vetted_queries.go # vetted CIS-8.1 query library
|
||||
@@ -319,7 +329,7 @@ Tunables (env vars) for the schema layer:
|
||||
### Adding a new tool
|
||||
|
||||
1. Add a method to `FleetClient` in `fleet_integration.go` that wraps the Fleet API call.
|
||||
2. Pick the right domain file (`mcp_tools_hosts.go`, `mcp_tools_queries.go`, or `mcp_tools_policies.go`) and add a `register<ToolName>` function.
|
||||
2. Pick the right domain file (`mcp_tools_hosts.go`, `mcp_tools_queries.go`, `mcp_tools_policies.go`, or `mcp_tools_inventory.go`) and add a `register<ToolName>` function.
|
||||
3. Wire the new register function into the matching `register<Domain>Tools` orchestrator at the top of the same file.
|
||||
4. Always set `readOnly` / `destructive` / `idempotent` annotations so Claude Desktop can advertise it.
|
||||
5. Build and run the smoke test from the [Smoke-test stdio mode](#smoke-test-stdio-mode-without-claude-desktop) section.
|
||||
|
||||
@@ -482,6 +482,225 @@ func (fc *FleetClient) GetHostByIdentifierWithPolicies(ctx context.Context, iden
|
||||
return &result.Host, nil
|
||||
}
|
||||
|
||||
// UID is uint64 because Fleet sends `uid` as a JSON number, not a string.
|
||||
type HostUser struct {
|
||||
UID uint64 `json:"uid"`
|
||||
Username string `json:"username"`
|
||||
Type string `json:"type"`
|
||||
GroupName string `json:"groupname"`
|
||||
Shell string `json:"shell"`
|
||||
}
|
||||
|
||||
type HostWithUsers struct {
|
||||
Endpoint
|
||||
Users []HostUser `json:"users"`
|
||||
}
|
||||
|
||||
type SoftwareVersion struct {
|
||||
ID uint `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Vulnerabilities []string `json:"vulnerabilities,omitempty"`
|
||||
}
|
||||
|
||||
type SoftwareTitle struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
VersionsCount int `json:"versions_count"`
|
||||
HostsCount int `json:"hosts_count"`
|
||||
Versions []SoftwareVersion `json:"versions,omitempty"`
|
||||
Browser string `json:"browser,omitempty"`
|
||||
ExtensionFor string `json:"extension_for,omitempty"`
|
||||
}
|
||||
|
||||
type HostSoftwareInstalledVersion struct {
|
||||
Version string `json:"version"`
|
||||
LastOpenedAt string `json:"last_opened_at,omitempty"`
|
||||
Vulnerabilities []string `json:"vulnerabilities,omitempty"`
|
||||
InstalledPaths []string `json:"installed_paths,omitempty"`
|
||||
}
|
||||
|
||||
type HostSoftware struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
BundleIdentifier string `json:"bundle_identifier,omitempty"`
|
||||
ExtensionFor string `json:"extension_for,omitempty"`
|
||||
InstalledVersions []HostSoftwareInstalledVersion `json:"installed_versions,omitempty"`
|
||||
}
|
||||
|
||||
func (fc *FleetClient) GetHostByIDWithUsers(ctx context.Context, hostID uint) (*HostWithUsers, error) {
|
||||
endpointPath := fmt.Sprintf("/api/v1/fleet/hosts/%d", hostID)
|
||||
resp, err := fc.makeFleetRequest(ctx, "GET", endpointPath, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get host with users by id: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("host not found: id=%d", hostID)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get host with users by id: status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Host HostWithUsers `json:"host"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode host with users by id response: %w", err)
|
||||
}
|
||||
return &result.Host, nil
|
||||
}
|
||||
|
||||
// Bounds memory for a single software fetch. var (not const) so tests can lower it.
|
||||
var fetchSoftwareHardCap = 5000
|
||||
|
||||
func matchesSoftwareSource(rowSource, want string) bool {
|
||||
if want == "" {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(rowSource, want)
|
||||
}
|
||||
|
||||
// source is filtered client-side (not a server-side param on this endpoint);
|
||||
// perPage caps the merged result.
|
||||
func (fc *FleetClient) GetHostSoftware(ctx context.Context, hostID uint, query, vulnerable, source string, perPage int) ([]HostSoftware, bool, error) {
|
||||
const apiPerPage = 500
|
||||
out := make([]HostSoftware, 0, perPage)
|
||||
for page := 0; ; page++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("per_page", strconv.Itoa(apiPerPage))
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
params.Set("query", q)
|
||||
}
|
||||
if v := strings.TrimSpace(vulnerable); v != "" {
|
||||
params.Set("vulnerable", v)
|
||||
}
|
||||
|
||||
endpointPath := fmt.Sprintf("/api/v1/fleet/hosts/%d/software?%s", hostID, params.Encode())
|
||||
resp, err := fc.makeFleetRequest(ctx, "GET", endpointPath, nil)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to fetch host software: %w", err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
resp.Body.Close()
|
||||
return nil, false, fmt.Errorf("host not found: id=%d", hostID)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
status := resp.StatusCode
|
||||
resp.Body.Close()
|
||||
return nil, false, fmt.Errorf("failed to fetch host software: status code %d", status)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Software []HostSoftware `json:"software"`
|
||||
}
|
||||
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
||||
resp.Body.Close()
|
||||
if decErr != nil {
|
||||
return nil, false, fmt.Errorf("failed to decode host software response: %w", decErr)
|
||||
}
|
||||
|
||||
shortPage := len(result.Software) < apiPerPage
|
||||
for _, row := range result.Software {
|
||||
if !matchesSoftwareSource(row.Source, source) {
|
||||
continue
|
||||
}
|
||||
out = append(out, row)
|
||||
if perPage > 0 && len(out) >= perPage {
|
||||
return out, false, nil
|
||||
}
|
||||
if len(out) >= fetchSoftwareHardCap {
|
||||
logrus.Warnf("host software fetch hit hard cap %d (host_id=%d) — result truncated; tighten filters or raise fetchSoftwareHardCap", fetchSoftwareHardCap, hostID)
|
||||
return out, true, nil
|
||||
}
|
||||
}
|
||||
if shortPage {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, false, nil
|
||||
}
|
||||
|
||||
func (fc *FleetClient) ListSoftwareTitles(ctx context.Context, teamName, platform, query, vulnerable, source string, perPage int) ([]SoftwareTitle, bool, error) {
|
||||
var teamIDStr string
|
||||
if teamName != "" {
|
||||
teamIDs, err := fc.resolveTeamNames(ctx, []string{teamName})
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to resolve fleet: %w", err)
|
||||
}
|
||||
teamIDStr = fmt.Sprintf("%d", teamIDs[0])
|
||||
}
|
||||
|
||||
const apiPerPage = 100 // titles endpoint returns expanded objects; lower page size keeps payloads small
|
||||
out := make([]SoftwareTitle, 0, perPage)
|
||||
for page := 0; ; page++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("per_page", strconv.Itoa(apiPerPage))
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
if teamIDStr != "" {
|
||||
params.Set("team_id", teamIDStr)
|
||||
}
|
||||
if p := strings.TrimSpace(platform); p != "" {
|
||||
params.Set("platform", p)
|
||||
}
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
params.Set("query", q)
|
||||
}
|
||||
if v := strings.TrimSpace(vulnerable); v != "" {
|
||||
params.Set("vulnerable", v)
|
||||
}
|
||||
|
||||
resp, err := fc.makeFleetRequest(ctx, "GET", "/api/v1/fleet/software/titles?"+params.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to fetch software titles: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
status := resp.StatusCode
|
||||
resp.Body.Close()
|
||||
return nil, false, fmt.Errorf("failed to fetch software titles: status code %d", status)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
SoftwareTitles []SoftwareTitle `json:"software_titles"`
|
||||
}
|
||||
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
||||
resp.Body.Close()
|
||||
if decErr != nil {
|
||||
return nil, false, fmt.Errorf("failed to decode software titles response: %w", decErr)
|
||||
}
|
||||
|
||||
shortPage := len(result.SoftwareTitles) < apiPerPage
|
||||
for _, row := range result.SoftwareTitles {
|
||||
if !matchesSoftwareSource(row.Source, source) {
|
||||
continue
|
||||
}
|
||||
out = append(out, row)
|
||||
if perPage > 0 && len(out) >= perPage {
|
||||
return out, false, nil
|
||||
}
|
||||
if len(out) >= fetchSoftwareHardCap {
|
||||
logrus.Warnf("software titles fetch hit hard cap %d — result truncated; tighten filters or raise fetchSoftwareHardCap", fetchSoftwareHardCap)
|
||||
return out, true, nil
|
||||
}
|
||||
}
|
||||
if shortPage {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, false, nil
|
||||
}
|
||||
|
||||
// GetQueries retrieves global and all team-specific queries from Fleet.
|
||||
func (fc *FleetClient) GetQueries(ctx context.Context) ([]Query, error) {
|
||||
resp, err := fc.makeFleetRequest(ctx, "GET", "/api/v1/fleet/reports", nil)
|
||||
@@ -854,7 +1073,6 @@ func (fc *FleetClient) fetchHostsFromPathBounded(ctx context.Context, path strin
|
||||
out := make([]Endpoint, 0, perPage)
|
||||
truncated := false
|
||||
for page := 0; ; page++ {
|
||||
// Honor caller cancellation between paginated requests.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -1312,7 +1530,6 @@ func (fc *FleetClient) GetHostsForCVE(ctx context.Context, cveID, teamName, plat
|
||||
}
|
||||
titleIDs := make([]uint, 0)
|
||||
for page := 0; ; page++ {
|
||||
// Honor caller cancellation between paginated requests.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
@@ -593,3 +594,479 @@ func TestCampaignWebsocketURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSoftwareTitles_PaginatesUntilShortPage(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/fleet/software/titles" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
calls.Add(1)
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
var titles []SoftwareTitle
|
||||
switch page {
|
||||
case 0:
|
||||
titles = make([]SoftwareTitle, 100)
|
||||
for i := range titles {
|
||||
titles[i] = SoftwareTitle{ID: uint(i + 1), Name: fmt.Sprintf("pkg%d", i), Source: "apps"}
|
||||
}
|
||||
case 1:
|
||||
titles = make([]SoftwareTitle, 25)
|
||||
for i := range titles {
|
||||
titles[i] = SoftwareTitle{ID: uint(100 + i + 1), Name: fmt.Sprintf("pkg%d", 100+i), Source: "apps"}
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected page %d", page)
|
||||
http.Error(w, "unexpected page", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
SoftwareTitles []SoftwareTitle `json:"software_titles"`
|
||||
}{SoftwareTitles: titles})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
fc := newTestClient(srv.URL)
|
||||
// perPage 0 means "no client-side cap" — paginate until the short page.
|
||||
out, truncated, err := fc.ListSoftwareTitles(context.Background(), "", "", "", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if truncated {
|
||||
t.Errorf("expected truncated=false")
|
||||
}
|
||||
if got, want := len(out), 125; got != want {
|
||||
t.Errorf("len(out) = %d, want %d", got, want)
|
||||
}
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Errorf("expected 2 page calls, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSoftwareTitles_AppliesSourceFilter(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/fleet/software/titles" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page > 0 {
|
||||
// Short page on page 1 to end pagination.
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
SoftwareTitles []SoftwareTitle `json:"software_titles"`
|
||||
}{})
|
||||
return
|
||||
}
|
||||
// Mixed-source payload: 3 npm, 2 python, 5 apps. Short page (8 < 100)
|
||||
// so pagination ends after this response.
|
||||
titles := []SoftwareTitle{
|
||||
{ID: 1, Name: "left-pad", Source: "npm_packages"},
|
||||
{ID: 2, Name: "lodash", Source: "npm_packages"},
|
||||
{ID: 3, Name: "axios", Source: "npm_packages"},
|
||||
{ID: 4, Name: "requests", Source: "python_packages"},
|
||||
{ID: 5, Name: "numpy", Source: "python_packages"},
|
||||
{ID: 6, Name: "Slack.app", Source: "apps"},
|
||||
{ID: 7, Name: "Chrome.app", Source: "apps"},
|
||||
{ID: 8, Name: "Zoom.app", Source: "apps"},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
SoftwareTitles []SoftwareTitle `json:"software_titles"`
|
||||
}{SoftwareTitles: titles})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
fc := newTestClient(srv.URL)
|
||||
out, _, err := fc.ListSoftwareTitles(context.Background(), "", "", "", "", "npm_packages", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := len(out), 3; got != want {
|
||||
t.Errorf("len(out) = %d, want %d (3 npm)", got, want)
|
||||
}
|
||||
for _, row := range out {
|
||||
if !strings.EqualFold(row.Source, "npm_packages") {
|
||||
t.Errorf("unexpected source %q in filtered result", row.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// Case-insensitive should also work.
|
||||
out2, _, err := fc.ListSoftwareTitles(context.Background(), "", "", "", "", "NPM_PACKAGES", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error (case-insensitive): %v", err)
|
||||
}
|
||||
if len(out2) != 3 {
|
||||
t.Errorf("case-insensitive filter returned %d rows, want 3", len(out2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHostSoftware_PropagatesTruncated(t *testing.T) {
|
||||
// Lower the cap so a small fixture trips truncation deterministically.
|
||||
orig := fetchSoftwareHardCap
|
||||
fetchSoftwareHardCap = 4
|
||||
t.Cleanup(func() { fetchSoftwareHardCap = orig })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/fleet/hosts/") || !strings.HasSuffix(r.URL.Path, "/software") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// Single page with 10 matching rows — hard cap of 4 should fire
|
||||
// before the page is fully consumed.
|
||||
rows := make([]HostSoftware, 10)
|
||||
for i := range rows {
|
||||
rows[i] = HostSoftware{ID: uint(i + 1), Name: fmt.Sprintf("pkg%d", i), Source: "apps"}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Software []HostSoftware `json:"software"`
|
||||
}{Software: rows})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
fc := newTestClient(srv.URL)
|
||||
// perPage 0 — don't short-circuit on client-side cap. Force the hard-cap
|
||||
// path to fire instead. source="" matches everything.
|
||||
out, truncated, err := fc.GetHostSoftware(context.Background(), 42, "", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !truncated {
|
||||
t.Errorf("expected truncated=true when hard cap fires")
|
||||
}
|
||||
if got, want := len(out), 4; got != want {
|
||||
t.Errorf("len(out) = %d, want %d (hard cap)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHostWithUsers_AmbiguousCandidates(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/api/v1/fleet/hosts":
|
||||
// Substring search returns multiple collisions.
|
||||
hosts := []Endpoint{
|
||||
{ID: 1, Name: "mac-1.local"},
|
||||
{ID: 2, Name: "mac-2.local"},
|
||||
{ID: 3, Name: "mac-3.local"},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Hosts []Endpoint `json:"hosts"`
|
||||
}{Hosts: hosts})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
fc := newTestClient(srv.URL)
|
||||
host, ambiguous, candidates, err := resolveHostWithUsers(context.Background(), fc, 0, "mac")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ambiguous {
|
||||
t.Errorf("expected ambiguous=true for multi-match identifier")
|
||||
}
|
||||
if host != nil {
|
||||
t.Errorf("expected host=nil when ambiguous, got %+v", host)
|
||||
}
|
||||
if got, want := len(candidates), 3; got != want {
|
||||
t.Errorf("len(candidates) = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHostByIDWithUsers_DecodesUsers(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/fleet/hosts/42" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"host": map[string]any{
|
||||
"id": 42,
|
||||
"hostname": "test.local",
|
||||
"users": []map[string]any{
|
||||
{"uid": 501, "username": "alice", "type": "regular", "groupname": "staff", "shell": "/bin/zsh"},
|
||||
{"uid": 502, "username": "bob", "type": "regular", "groupname": "staff", "shell": "/bin/bash"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
fc := newTestClient(srv.URL)
|
||||
host, err := fc.GetHostByIDWithUsers(context.Background(), 42)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host == nil {
|
||||
t.Fatalf("nil host")
|
||||
}
|
||||
if got, want := host.ID, uint(42); got != want {
|
||||
t.Errorf("host.ID = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := len(host.Users), 2; got != want {
|
||||
t.Errorf("len(users) = %d, want %d", got, want)
|
||||
}
|
||||
if host.Users[0].Username != "alice" || host.Users[1].Shell != "/bin/bash" {
|
||||
t.Errorf("user decode mismatch: %+v", host.Users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterHostUsers_CaseInsensitiveAcrossFields(t *testing.T) {
|
||||
users := []HostUser{
|
||||
{UID: 501, Username: "alice", GroupName: "staff", Shell: "/bin/zsh"},
|
||||
{UID: 502, Username: "bob", GroupName: "wheel", Shell: "/bin/bash"},
|
||||
{UID: 0, Username: "root", GroupName: "wheel", Shell: "/bin/sh"},
|
||||
}
|
||||
cases := []struct {
|
||||
query string
|
||||
want int
|
||||
}{
|
||||
{"alice", 1}, // username exact
|
||||
{"ALICE", 1}, // case-insensitive
|
||||
{"wheel", 2}, // groupname
|
||||
{"bash", 1}, // shell
|
||||
{"50", 2}, // uid prefix (matches 501, 502)
|
||||
{"nomatch", 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := filterHostUsers(users, tc.query)
|
||||
if len(got) != tc.want {
|
||||
t.Errorf("filterHostUsers(%q) returned %d, want %d", tc.query, len(got), tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGetSoftwareArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
perHost bool
|
||||
fleet, platform, vulnerable string
|
||||
wantErr bool
|
||||
}{
|
||||
{"per-host alone ok", true, "", "", "", false},
|
||||
{"per-host + fleet rejected", true, "Workstations", "", "", true},
|
||||
{"per-host + platform rejected", true, "", "macos", "", true},
|
||||
{"cross-host none ok (full inventory)", false, "", "", "", false},
|
||||
{"cross-host fleet alone ok", false, "Workstations", "", "", false},
|
||||
{"cross-host platform alone rejected", false, "", "macos", "", true},
|
||||
{"cross-host platform + fleet ok", false, "Workstations", "macos", "", false},
|
||||
{"vulnerable=true ok", false, "", "", "true", false},
|
||||
{"vulnerable=false ok", false, "", "", "false", false},
|
||||
{"vulnerable bad value rejected", false, "", "", "maybe", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateGetSoftwareArgs(tc.perHost, tc.fleet, tc.platform, tc.vulnerable)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesSoftwareSource(t *testing.T) {
|
||||
cases := []struct {
|
||||
row, want string
|
||||
expect bool
|
||||
}{
|
||||
{"apps", "", true}, // empty want matches anything
|
||||
{"apps", "apps", true}, // exact
|
||||
{"NPM_Packages", "npm_packages", true}, // case-insensitive
|
||||
{"deb_packages", "apps", false}, // mismatch
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := matchesSoftwareSource(tc.row, tc.want); got != tc.expect {
|
||||
t.Errorf("matchesSoftwareSource(%q,%q) = %v, want %v", tc.row, tc.want, got, tc.expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHost_NumericFetchesByID(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1/fleet/hosts/42" {
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Host Endpoint `json:"host"`
|
||||
}{Host: Endpoint{ID: 42, Name: "h42.local"}})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
fc := newTestClient(srv.URL)
|
||||
|
||||
// numeric host_id is verified via GetHostByID (confirms it exists, gets the name)
|
||||
host, _, ambiguous, err := resolveHost(context.Background(), fc, 42, "")
|
||||
if err != nil || ambiguous || host == nil || host.ID != 42 || host.Name != "h42.local" {
|
||||
t.Fatalf("numeric: host=%+v ambiguous=%v err=%v, want id=42 with name", host, ambiguous, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostIDArg(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want uint
|
||||
wantErr bool
|
||||
}{
|
||||
{"", 0, false},
|
||||
{"42", 42, false},
|
||||
{"abc", 0, true},
|
||||
{"0", 0, true},
|
||||
{"-1", 0, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := parseHostIDArg(tc.in)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("parseHostIDArg(%q): expected error, got nil", tc.in)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || got != tc.want {
|
||||
t.Errorf("parseHostIDArg(%q) = (%d, %v), want (%d, nil)", tc.in, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHost_IdentifierSingleAndFallback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/api/v1/fleet/hosts":
|
||||
hosts := []Endpoint{}
|
||||
if r.URL.Query().Get("query") == "solo" { // single unambiguous match
|
||||
hosts = []Endpoint{{ID: 7, Name: "solo.local"}}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Hosts []Endpoint `json:"hosts"`
|
||||
}{Hosts: hosts})
|
||||
case r.URL.Path == "/api/v1/fleet/hosts/identifier/ghost":
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Host Endpoint `json:"host"`
|
||||
}{Host: Endpoint{ID: 9, Name: "ghost.local"}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
fc := newTestClient(srv.URL)
|
||||
|
||||
// single substring match -> that host, not ambiguous
|
||||
host, _, ambiguous, err := resolveHost(context.Background(), fc, 0, "solo")
|
||||
if err != nil || ambiguous || host == nil || host.ID != 7 {
|
||||
t.Fatalf("single match: host=%+v ambiguous=%v err=%v, want id=7", host, ambiguous, err)
|
||||
}
|
||||
// zero substring matches -> identifier-endpoint fallback
|
||||
host, _, ambiguous, err = resolveHost(context.Background(), fc, 0, "ghost")
|
||||
if err != nil || ambiguous || host == nil || host.ID != 9 {
|
||||
t.Fatalf("fallback: host=%+v ambiguous=%v err=%v, want id=9", host, ambiguous, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHostWithUsers_SingleMatchAndFallback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/fleet/hosts":
|
||||
hosts := []Endpoint{}
|
||||
if r.URL.Query().Get("query") == "solo" {
|
||||
hosts = []Endpoint{{ID: 5, Name: "solo.local"}}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Hosts []Endpoint `json:"hosts"`
|
||||
}{Hosts: hosts})
|
||||
case "/api/v1/fleet/hosts/5":
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Host HostWithUsers `json:"host"`
|
||||
}{Host: HostWithUsers{Endpoint: Endpoint{ID: 5, Name: "solo.local"}, Users: []HostUser{{UID: 501, Username: "alice"}}}})
|
||||
case "/api/v1/fleet/hosts/identifier/ghost":
|
||||
// identifier endpoint resolves the host but carries NO users
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Host Endpoint `json:"host"`
|
||||
}{Host: Endpoint{ID: 9, Name: "ghost.local"}})
|
||||
case "/api/v1/fleet/hosts/9":
|
||||
// users come from the by-id refetch
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Host HostWithUsers `json:"host"`
|
||||
}{Host: HostWithUsers{Endpoint: Endpoint{ID: 9, Name: "ghost.local"}, Users: []HostUser{{UID: 0, Username: "root"}}}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
fc := newTestClient(srv.URL)
|
||||
|
||||
// single match -> fetched by id, users populated
|
||||
host, ambiguous, _, err := resolveHostWithUsers(context.Background(), fc, 0, "solo")
|
||||
if err != nil || ambiguous || host == nil || host.ID != 5 || len(host.Users) != 1 {
|
||||
t.Fatalf("single match: host=%+v ambiguous=%v err=%v", host, ambiguous, err)
|
||||
}
|
||||
// zero matches -> identifier endpoint (no users) then by-id refetch (users)
|
||||
host, ambiguous, _, err = resolveHostWithUsers(context.Background(), fc, 0, "ghost")
|
||||
if err != nil || ambiguous || host == nil || host.ID != 9 || len(host.Users) != 1 || host.Users[0].Username != "root" {
|
||||
t.Fatalf("fallback: host=%+v ambiguous=%v err=%v", host, ambiguous, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHostSoftware_DecodesNestedInstalledVersions(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/software") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"software":[{"id":1,"name":"curl","source":"deb_packages","installed_versions":[{"version":"7.88.1","vulnerabilities":["CVE-2026-1111"],"installed_paths":["/usr/bin/curl"]}]}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
fc := newTestClient(srv.URL)
|
||||
|
||||
out, _, err := fc.GetHostSoftware(context.Background(), 1, "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(out) != 1 || len(out[0].InstalledVersions) != 1 {
|
||||
t.Fatalf("decoded = %+v, want 1 row with 1 installed version", out)
|
||||
}
|
||||
v := out[0].InstalledVersions[0]
|
||||
if v.Version != "7.88.1" || len(v.Vulnerabilities) != 1 || v.Vulnerabilities[0] != "CVE-2026-1111" || len(v.InstalledPaths) != 1 {
|
||||
t.Errorf("nested installed_version not decoded: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHostSoftware_SourceFilterAndPerPage(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/software") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rows := []HostSoftware{
|
||||
{ID: 1, Name: "a", Source: "apps"},
|
||||
{ID: 2, Name: "b", Source: "deb_packages"},
|
||||
{ID: 3, Name: "c", Source: "apps"},
|
||||
{ID: 4, Name: "d", Source: "npm_packages"},
|
||||
{ID: 5, Name: "e", Source: "apps"},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Software []HostSoftware `json:"software"`
|
||||
}{Software: rows})
|
||||
}))
|
||||
defer srv.Close()
|
||||
fc := newTestClient(srv.URL)
|
||||
|
||||
// source=apps keeps only apps rows; perPage=2 caps the merged result early
|
||||
out, truncated, err := fc.GetHostSoftware(context.Background(), 42, "", "", "apps", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if truncated {
|
||||
t.Errorf("expected truncated=false when perPage is reached")
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("len(out) = %d, want 2 (perPage cap on matching rows)", len(out))
|
||||
}
|
||||
for _, sw := range out {
|
||||
if sw.Source != "apps" {
|
||||
t.Errorf("source filter leaked non-apps row: %+v", sw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
@@ -10,6 +11,61 @@ import (
|
||||
"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.
|
||||
|
||||
@@ -51,6 +51,7 @@ func SetupMCPServer(config *Config, fleetClient *FleetClient) *server.MCPServer
|
||||
registerHostTools(s, fleetClient)
|
||||
registerQueryTools(s, fleetClient)
|
||||
registerPolicyTools(s, fleetClient)
|
||||
registerInventoryTools(s, fleetClient)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
@@ -108,11 +107,11 @@ func registerGetHost(s *server.MCPServer, fleetClient *FleetClient) {
|
||||
|
||||
// Case 1: explicit numeric host_id wins. Always exact.
|
||||
if hostIDArg != "" {
|
||||
id, parseErr := strconv.ParseUint(hostIDArg, 10, 64)
|
||||
if parseErr != nil || id == 0 || id > uint64(^uint(0)) {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("host_id must be a positive integer, got %q", hostIDArg)), nil
|
||||
id, err := parseHostIDArg(hostIDArg)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
host, err := fleetClient.GetHostByID(ctx, uint(id))
|
||||
host, err := fleetClient.GetHostByID(ctx, id)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to get host by id: %v", err)), nil
|
||||
}
|
||||
@@ -249,14 +248,18 @@ func registerGetHostPolicies(s *server.MCPServer, fleetClient *FleetClient) {
|
||||
identifier := getOptionalString(request, "identifier")
|
||||
responseFilter := getOptionalString(request, "response")
|
||||
|
||||
if hostIDArg == "" && identifier == "" {
|
||||
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 := resolveHostWithPolicies(ctx, fleetClient, hostIDArg, identifier)
|
||||
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
|
||||
}
|
||||
@@ -313,69 +316,3 @@ func registerGetHostPolicies(s *server.MCPServer, fleetClient *FleetClient) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// resolveHostWithPolicies turns a (host_id, identifier) pair into a single
|
||||
// authoritative host with populated policies, OR a candidate list when the
|
||||
// identifier is ambiguous.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. host_id set (numeric) → /hosts/:host_id?populate_policies=true. Exact.
|
||||
// 2. identifier non-numeric → query-first: /hosts?query=identifier
|
||||
// - 0 matches → fall back to /hosts/identifier/:id (catches UUIDs, which
|
||||
// Fleet's substring search doesn't index).
|
||||
// - 1 match → fetch the resolved host by ID (ID-path is the only way to
|
||||
// guarantee no silent collision when hostnames are duplicated).
|
||||
// - 2+ matches → return ambiguous=true with candidates so the caller can
|
||||
// re-call with host_id.
|
||||
//
|
||||
// Reasoning: Fleet's /hosts/identifier/:id endpoint silently returns ONE
|
||||
// host when multiple share the same hostname — giving callers the wrong
|
||||
// host with no warning. Going through the query endpoint first surfaces
|
||||
// collisions, then the explicit /hosts/:id resolves the chosen one with
|
||||
// no further ambiguity.
|
||||
func resolveHostWithPolicies(ctx context.Context, fleetClient *FleetClient, hostIDArg, identifier string) (host *HostWithPolicies, ambiguous bool, candidates []Endpoint, err error) {
|
||||
// Case 1: explicit numeric host_id wins.
|
||||
if hostIDArg != "" {
|
||||
id, parseErr := strconv.ParseUint(hostIDArg, 10, strconv.IntSize)
|
||||
if parseErr != nil || id == 0 {
|
||||
return nil, false, nil, fmt.Errorf("host_id must be a positive integer, got %q", hostIDArg)
|
||||
}
|
||||
h, hErr := fleetClient.GetHostByIDWithPolicies(ctx, uint(id))
|
||||
if hErr != nil {
|
||||
return nil, false, nil, hErr
|
||||
}
|
||||
return h, false, nil, nil
|
||||
}
|
||||
|
||||
// Case 2: identifier path — query first to detect collisions.
|
||||
// Cap at 50 candidates: Fleet's substring matcher is permissive (e.g.
|
||||
// "mac" hits hundreds of hosts) so we need headroom for true collisions
|
||||
// to surface. 50 keeps the disambiguation list bounded for the AI client.
|
||||
const maxCandidates = 50
|
||||
cands, qErr := fleetClient.GetEndpointsWithFilters(ctx, "", "", "", identifier, "", "", "", maxCandidates)
|
||||
|
||||
if qErr == nil && len(cands) == 1 {
|
||||
// One unambiguous match. Fetch by ID for guaranteed no-collision and
|
||||
// to populate policies (the substring search doesn't return them).
|
||||
h, hErr := fleetClient.GetHostByIDWithPolicies(ctx, cands[0].ID)
|
||||
if hErr != nil {
|
||||
// API hiccup — the search did find the host but the ID lookup
|
||||
// failed. Return error rather than guess.
|
||||
return nil, false, nil, hErr
|
||||
}
|
||||
return h, false, nil, nil
|
||||
}
|
||||
if qErr == nil && len(cands) > 1 {
|
||||
// Multiple hosts match the substring — caller must disambiguate.
|
||||
return nil, true, cands, nil
|
||||
}
|
||||
|
||||
// Zero query matches OR query failed: fall back to the identifier
|
||||
// endpoint for UUIDs and other identifiers Fleet's substring index
|
||||
// doesn't reach.
|
||||
h, idErr := fleetClient.GetHostByIdentifierWithPolicies(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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func registerInventoryTools(s *server.MCPServer, fleetClient *FleetClient) {
|
||||
registerGetSoftware(s, fleetClient)
|
||||
registerGetHostUsers(s, fleetClient)
|
||||
}
|
||||
|
||||
func validateGetSoftwareArgs(perHost bool, fleet, platform, vulnerable string) error {
|
||||
if perHost && (fleet != "" || platform != "") {
|
||||
return fmt.Errorf("host_id/host_identifier are mutually exclusive with fleet/platform — pick per-host or cross-host mode")
|
||||
}
|
||||
if vulnerable != "" && vulnerable != "true" && vulnerable != "false" {
|
||||
return fmt.Errorf("vulnerable must be 'true' or 'false', got %q", vulnerable)
|
||||
}
|
||||
if !perHost && platform != "" && fleet == "" {
|
||||
return fmt.Errorf("platform requires fleet in cross-host mode — Fleet's software/titles endpoint only filters by platform when a team is also set")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerGetSoftware(s *server.MCPServer, fleetClient *FleetClient) {
|
||||
tool := mcp.NewTool("get_software",
|
||||
mcp.WithDescription("List software/packages from Fleet's stored host inventory (refreshed on each host check-in — works even when hosts are offline). Two modes, picked automatically:\n\n- PER-HOST mode (when host_id OR host_identifier is set): every package installed on that host, including version, source, install paths, and any matching CVEs. Use this for 'what's on host X?' questions.\n- CROSS-HOST mode (no host arg): software TITLES seen across hosts, optionally scoped by fleet/platform/vulnerability. Use this for 'do we have python on any Workstation?' or 'every npm package across the fleet'.\n\nThe `source` arg is the osquery source-table name (e.g. 'npm_packages', 'python_packages', 'apps', 'deb_packages', 'rpm_packages', 'chrome_extensions', 'vscode_extensions', 'homebrew_packages') and is matched client-side case-insensitively. Use `query` for a substring match on software name OR a CVE id ('CVE-2026-12345') — server-side, fast. Prefer this tool over run_live_query for inventory lookups: the cached data is always-available and doesn't burn host CPU."),
|
||||
mcp.WithString("host_id", mcp.Description("Numeric Fleet host ID. Switches to per-host mode. Mutually exclusive with fleet/platform.")),
|
||||
mcp.WithString("host_identifier", mcp.Description("Exact hostname / UUID / serial OR a substring (same disambiguation as get_host). Switches to per-host mode. Mutually exclusive with fleet/platform.")),
|
||||
mcp.WithString("fleet", mcp.Description("Fleet name (e.g. 'Workstations') — cross-host mode only. Resolved via get_fleets.")),
|
||||
mcp.WithString("platform", mcp.Description("Cross-host mode only, and REQUIRES `fleet` (Fleet's software/titles endpoint only filters by platform together with a team). One of: macos, windows, linux, chrome, ios, ipados.")),
|
||||
mcp.WithString("vulnerable", mcp.Description("'true' to show only software with known CVEs; 'false' or omitted shows all.")),
|
||||
mcp.WithString("source", mcp.Description("osquery source table (e.g. 'npm_packages', 'python_packages', 'apps', 'deb_packages', 'chrome_extensions'). Client-side case-insensitive filter — Fleet doesn't accept this server-side.")),
|
||||
mcp.WithString("query", mcp.Description("Substring (case-insensitive) matched against software name OR a CVE id. Server-side. Use for plain 'do we have X?' lookups.")),
|
||||
mcp.WithString("per_page", mcp.Description("Max rows in the merged result (default 50, max 200). Applied AFTER the source filter so the cap reflects the filtered set.")),
|
||||
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_software")
|
||||
|
||||
hostIDArg := getOptionalString(request, "host_id")
|
||||
identifier := getOptionalString(request, "host_identifier")
|
||||
fleet := getOptionalString(request, "fleet")
|
||||
platform := getOptionalString(request, "platform")
|
||||
vulnerable := getOptionalString(request, "vulnerable")
|
||||
source := getOptionalString(request, "source")
|
||||
query := getOptionalString(request, "query")
|
||||
perPage := parsePerPageArg(request, defaultEndpointsPerPage)
|
||||
|
||||
hostID, err := parseHostIDArg(hostIDArg)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
perHost := hostID != 0 || identifier != ""
|
||||
if err := validateGetSoftwareArgs(perHost, fleet, platform, vulnerable); err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
if perHost {
|
||||
host, candidates, ambiguous, rErr := resolveHost(ctx, fleetClient, hostID, identifier)
|
||||
if rErr != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to resolve host: %v", rErr)), nil
|
||||
}
|
||||
if ambiguous {
|
||||
return jsonResult(map[string]interface{}{
|
||||
"message": fmt.Sprintf("%d hosts match %q. 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,
|
||||
})
|
||||
}
|
||||
|
||||
software, truncated, err := fleetClient.GetHostSoftware(ctx, host.ID, query, vulnerable, source, perPage)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch host software: %v", err)), nil
|
||||
}
|
||||
|
||||
return jsonResult(struct {
|
||||
Scope string `json:"scope"`
|
||||
HostID uint `json:"host_id"`
|
||||
HostName string `json:"host_name,omitempty"`
|
||||
Returned int `json:"returned"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
Software []HostSoftware `json:"software"`
|
||||
}{
|
||||
Scope: "host",
|
||||
HostID: host.ID,
|
||||
HostName: host.Name,
|
||||
Returned: len(software),
|
||||
Truncated: truncated,
|
||||
Software: software,
|
||||
})
|
||||
}
|
||||
|
||||
titles, truncated, err := fleetClient.ListSoftwareTitles(ctx, fleet, platform, query, vulnerable, source, perPage)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to list software titles: %v", err)), nil
|
||||
}
|
||||
|
||||
return jsonResult(struct {
|
||||
Scope string `json:"scope"`
|
||||
Fleet string `json:"fleet,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Returned int `json:"returned"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
SoftwareTitles []SoftwareTitle `json:"software_titles"`
|
||||
}{
|
||||
Scope: "titles",
|
||||
Fleet: fleet,
|
||||
Platform: platform,
|
||||
Returned: len(titles),
|
||||
Truncated: truncated,
|
||||
SoftwareTitles: titles,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func registerGetHostUsers(s *server.MCPServer, fleetClient *FleetClient) {
|
||||
tool := mcp.NewTool("get_host_users",
|
||||
mcp.WithDescription("List OS-local user accounts on a single host as inventoried by osquery (uid, username, type, groupname, shell). Returned from Fleet's stored host detail — works even when the host is currently offline. Use this for 'which accounts exist on host X?', 'is there a user named X on this host?', or to enumerate service accounts.\n\nIDENTIFIER GUIDANCE: pass `host_id` (numeric) when known — unambiguous. `host_identifier` accepts an exact hostname / UUID / serial OR a substring (same disambiguation as get_host). On collision returns a candidate list — re-call with `host_id` from the candidate you want.\n\nOptional `query` substring filters the returned users array client-side against username / uid / groupname / shell."),
|
||||
mcp.WithString("host_id", mcp.Description("Numeric Fleet host ID. Preferred when known — unambiguous.")),
|
||||
mcp.WithString("host_identifier", mcp.Description("Exact hostname / UUID / serial OR a substring. Required if host_id is not set. Does NOT match display_name — use host_id for display-name-only hosts.")),
|
||||
mcp.WithString("query", mcp.Description("Optional case-insensitive substring filter on username / uid / groupname / shell. Client-side.")),
|
||||
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_users")
|
||||
|
||||
hostIDArg := getOptionalString(request, "host_id")
|
||||
identifier := getOptionalString(request, "host_identifier")
|
||||
query := getOptionalString(request, "query")
|
||||
|
||||
hostID, err := parseHostIDArg(hostIDArg)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
if hostID == 0 && identifier == "" {
|
||||
return mcp.NewToolResultError("either host_id or host_identifier is required"), nil
|
||||
}
|
||||
|
||||
host, ambiguous, candidates, err := resolveHostWithUsers(ctx, fleetClient, hostID, identifier)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to get host users: %v", err)), nil
|
||||
}
|
||||
if ambiguous {
|
||||
return jsonResult(map[string]interface{}{
|
||||
"message": fmt.Sprintf("%d hosts match %q. 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,
|
||||
})
|
||||
}
|
||||
|
||||
users := host.Users
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
users = filterHostUsers(users, q)
|
||||
}
|
||||
|
||||
return jsonResult(struct {
|
||||
Host Endpoint `json:"host"`
|
||||
Returned int `json:"returned"`
|
||||
Users []HostUser `json:"users"`
|
||||
}{
|
||||
Host: host.Endpoint,
|
||||
Returned: len(users),
|
||||
Users: users,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// hostID is the validated host_id (0 means none — fall back to identifier).
|
||||
// Query-first so hostname collisions surface as candidates before the
|
||||
// identifier-endpoint fallback. Returns the resolved host so callers don't
|
||||
// re-fetch it just for the hostname.
|
||||
func resolveHost(ctx context.Context, fleetClient *FleetClient, hostID uint, identifier string) (host *Endpoint, candidates []Endpoint, ambiguous bool, err error) {
|
||||
if hostID != 0 {
|
||||
h, hErr := fleetClient.GetHostByID(ctx, hostID)
|
||||
if hErr != nil {
|
||||
return nil, nil, false, hErr
|
||||
}
|
||||
return h, nil, false, nil
|
||||
}
|
||||
|
||||
const maxCandidates = 50
|
||||
cands, qErr := fleetClient.GetEndpointsWithFilters(ctx, "", "", "", identifier, "", "", "", maxCandidates)
|
||||
|
||||
if qErr == nil && len(cands) == 1 {
|
||||
return &cands[0], nil, false, nil
|
||||
}
|
||||
if qErr == nil && len(cands) > 1 {
|
||||
return nil, cands, true, nil
|
||||
}
|
||||
|
||||
// Identifier fallback catches UUIDs the substring index misses.
|
||||
h, idErr := fleetClient.GetHostByIdentifier(ctx, identifier)
|
||||
if idErr != nil {
|
||||
return nil, nil, false, 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, nil, false, nil
|
||||
}
|
||||
|
||||
func resolveHostWithUsers(ctx context.Context, fleetClient *FleetClient, hostID uint, identifier string) (*HostWithUsers, bool, []Endpoint, error) {
|
||||
byIdentifier := func(ctx context.Context, ident string) (*HostWithUsers, error) {
|
||||
ep, err := fleetClient.GetHostByIdentifier(ctx, ident)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The identifier endpoint doesn't populate users, so the fallback resolves the
|
||||
// id there and refetches by id (which does).
|
||||
return fleetClient.GetHostByIDWithUsers(ctx, ep.ID)
|
||||
}
|
||||
return resolveHostDetail(ctx, fleetClient, hostID, identifier, fleetClient.GetHostByIDWithUsers, byIdentifier)
|
||||
}
|
||||
|
||||
func filterHostUsers(users []HostUser, q string) []HostUser {
|
||||
needle := strings.ToLower(q)
|
||||
out := make([]HostUser, 0, len(users))
|
||||
for _, u := range users {
|
||||
uidStr := strconv.FormatUint(u.UID, 10)
|
||||
if strings.Contains(strings.ToLower(u.Username), needle) ||
|
||||
strings.Contains(uidStr, needle) ||
|
||||
strings.Contains(strings.ToLower(u.GroupName), needle) ||
|
||||
strings.Contains(strings.ToLower(u.Shell), needle) {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user