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:
Nico
2026-06-30 13:01:49 -03:00
committed by GitHub
parent ddbcceb42c
commit a4086dcec0
7 changed files with 1011 additions and 77 deletions
+12 -2
View File
@@ -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.
+219 -2
View File
@@ -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
}
+477
View File
@@ -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)
}
}
}
+56
View File
@@ -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.
+1
View File
@@ -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
}
+10 -73
View File
@@ -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
}
+236
View File
@@ -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
}