Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,32 @@ commit types the repo already uses (`feat!`/`build!` for a breaking change).

## Unreleased

### Behaviour — the MCP `list_commands` tool browses and searches the catalog

`list_commands` returned the whole catalog in one result. That result was
larger than the 256 KiB cap on a child's output, so the tool cut it
mid-string: the model got invalid JSON with no Protect, School or Security
Cloud commands.

The tool now takes two optional arguments, `prefix` and `query`, and returns
one JSON object per line:

- With no arguments, it lists the top level. A row with `"subcommands": N`
has N commands under it.
- `prefix` opens one command path, for example `"pro"` or `"pro computers"`.
A runnable command is listed with `description`, `destructive` and `flags`.
- `query` returns the commands whose path, description or aliases contain
every word, for example `"delete policy"`.

Each result is kept under 40 KiB. Claude Code saves a text tool result
longer than 50,000 characters to a file, and gives the model only the file
path. An MCP client that parsed the old array gets NDJSON rows
now. The old result was always cut and invalid, so no client parsed it.

`jamf-cli commands` takes the same selection as `--prefix <path>`,
`--children` and `--search <words>`. With no flags it prints the whole
catalog, as before.

### Behaviour — computer group member counts come from the collection that carries one

`pro group-tools` and `pro audit` read member counts from
Expand Down
15 changes: 14 additions & 1 deletion internal/commands/agent_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,25 @@ privileges it requires (from the spec's `x-required-privileges`); the field is
omitted when no privileges are declared. Classic, Protect, and School commands do
not carry privilege data.

The whole catalog is large. To read part of it:

- `jamf-cli commands --children` lists the top level. Each row with commands
under it carries `"subcommands": N`.
- `jamf-cli commands --prefix "pro computers" --children` lists one level down.
`--prefix` without `--children` lists everything under the path.
- `jamf-cli commands --search "delete policy"` lists the commands whose path,
description or aliases contain every word.

## MCP

`jamf-cli mcp serve` exposes the command tree to MCP clients over stdio via three
tools:

- `list_commands` — the catalog.
- `list_commands` — browse or search the catalog, one JSON object per line.
With no arguments it lists the top level. A row with `"subcommands": N` has
N commands under it: pass its `command` as `prefix` to open it. Pass `query`
to find commands by words, with or without `prefix`. For one command's
arguments, call `run_command` with `<command> --help`.
- `run_command` — execute one command and get its output back as text.
- `generate_report` — write a self-contained HTML fleet report into the
directory `jamf-cli config set-report-dir` designates, and return its path and
Expand Down
2 changes: 2 additions & 0 deletions internal/commands/commands_catalog_projection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ func populatedEntry(t *testing.T) commandEntry {
f.SetString("x")
case reflect.Bool:
f.SetBool(true)
case reflect.Int:
f.SetInt(1)
case reflect.Slice:
if f.Type().Elem().Kind() != reflect.String {
t.Fatalf("commandEntry.%s is a slice of %s, which this populator cannot fill — extend it",
Expand Down
236 changes: 236 additions & 0 deletions internal/commands/commands_catalog_query_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// Copyright 2026, Jamf Software LLC

package commands

import (
"encoding/json"
"reflect"
"strings"
"testing"
)

// renderCatalogNDJSON renders entries the way `commands -o ndjson --select
// <fields>` prints them: one compact JSON object per row, projected to fields.
func renderCatalogNDJSON(t *testing.T, entries []commandEntry, fields string) string {
t.Helper()
keep := strings.Split(fields, ",")
var b strings.Builder
for _, row := range commandEntriesToMaps(entries, true) {
projected := map[string]any{}
for _, k := range keep {
if v, ok := row[k]; ok {
projected[k] = v
}
}
line, err := json.Marshal(projected)
if err != nil {
t.Fatal(err)
}
b.Write(line)
b.WriteByte('\n')
}
return b.String()
}

// TestQueryCatalog_ChildrenReachEveryCommandOnceUnderTheCeiling walks the tree
// the way list_commands does, one --children level at a time from the top.
func TestQueryCatalog_ChildrenReachEveryCommandOnceUnderTheCeiling(t *testing.T) {
root := NewRootCmd("test", "abc123", "2024-01-01", "unknown")
full := map[string]commandEntry{}
for _, e := range collectCommands(root, "", "", "") {
full[e.Command] = e
}

reached := map[string]int{}
queue := []string{""}
var levels, largest int
for len(queue) > 0 {
prefix := queue[0]
queue = queue[1:]
levels++

entries, err := queryCatalog(root, catalogQuery{Prefix: prefix, Children: true})
if err != nil {
t.Fatalf("prefix %q: %v", prefix, err)
}
if size := len(renderCatalogNDJSON(t, entries, listCommandsBrowseFields)); size > maxListCommandsBytes {
t.Errorf("prefix %q renders %d bytes, over the %d-byte list_commands ceiling", prefix, size, maxListCommandsBytes)
} else if size > largest {
largest = size
}

for _, e := range entries {
if e.Subcommands > 0 && e.Command != prefix {
queue = append(queue, e.Command)
}
want, ok := full[e.Command]
if !ok {
if e.Subcommands == 0 {
t.Errorf("prefix %q: %q is neither a catalog command nor a group", prefix, e.Command)
}
continue
}
reached[e.Command]++
got := e
got.Subcommands = 0
if !reflect.DeepEqual(got, want) {
t.Errorf("prefix %q: %q differs from its full-catalog entry:\n got %+v\nwant %+v", prefix, e.Command, got, want)
}
}
}

for command := range full {
if reached[command] != 1 {
t.Errorf("%q reached %d times walking --children levels, want 1", command, reached[command])
}
}
t.Logf("%d levels, largest %d bytes, %d commands", levels, largest, len(full))
}

func TestQueryCatalog_PrefixIsTheFullCatalogSubtreeAndTakesAliases(t *testing.T) {
root := NewRootCmd("test", "abc123", "2024-01-01", "unknown")

got, err := queryCatalog(root, catalogQuery{Prefix: "pro computers"})
if err != nil {
t.Fatal(err)
}
var want []commandEntry
for _, e := range collectCommands(root, "", "", "") {
if e.Command == "pro computer-inventory" || strings.HasPrefix(e.Command, "pro computer-inventory ") {
want = append(want, e)
}
}
if len(want) == 0 {
t.Fatal("the full catalog has no pro computer-inventory commands; pick another resource")
}
if !reflect.DeepEqual(got, want) {
t.Errorf("--prefix \"pro computers\" returned %d entries, want the %d pro computer-inventory entries of the full catalog", len(got), len(want))
}
}

func TestQueryCatalog_ChildrenOfACommandIsTheCommandItself(t *testing.T) {
root := NewRootCmd("test", "abc123", "2024-01-01", "unknown")
got, err := queryCatalog(root, catalogQuery{Prefix: "version", Children: true})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Command != "version" {
t.Errorf("--prefix version --children must return the version command alone, got %+v", got)
}
}

func TestQueryCatalog_RefusesAnUnknownPrefix(t *testing.T) {
root := NewRootCmd("test", "abc123", "2024-01-01", "unknown")
_, err := queryCatalog(root, catalogQuery{Prefix: "pro no-such-resource"})
if err == nil || !strings.Contains(err.Error(), "no-such-resource") {
t.Fatalf("an unknown prefix must be refused naming the unknown word, got %v", err)
}
}

func TestQueryCatalog_SearchMatchesEveryWordAcrossPlurals(t *testing.T) {
root := NewRootCmd("test", "abc123", "2024-01-01", "unknown")

got, err := queryCatalog(root, catalogQuery{Search: "delete policy"})
if err != nil {
t.Fatal(err)
}
var found bool
for _, e := range got {
if e.Command == "pro classic-policies delete" {
found = true
}
hay := strings.ToLower(e.Command + " " + e.Description)
if !strings.Contains(hay, "delet") || !strings.Contains(hay, "polic") {
t.Errorf("%q matched \"delete policy\" without both words", e.Command)
}
}
if !found {
t.Errorf("\"delete policy\" must find \"pro classic-policies delete\", got %d rows", len(got))
}

scoped, err := queryCatalog(root, catalogQuery{Prefix: "protect", Search: "list"})
if err != nil {
t.Fatal(err)
}
if len(scoped) == 0 {
t.Fatal("\"list\" under protect found nothing")
}
for _, e := range scoped {
if !strings.HasPrefix(e.Command, "protect ") {
t.Errorf("a search under --prefix protect returned %q", e.Command)
}
}
}

func TestCommandsCmd_ChildrenFlagPrintsSubcommandCounts(t *testing.T) {
stdout, _, err := runRoot(t, "commands", "--children", "-o", "json")
if err != nil {
t.Fatalf("commands --children failed: %v", err)
}
counts := map[string]float64{}
for _, row := range commandRows(t, stdout) {
if n, ok := row["subcommands"].(float64); ok {
counts[row["command"].(string)] = n
}
}
if counts["pro"] == 0 {
t.Errorf("commands --children must count the commands under pro, got %v", counts)
}
}

func TestQueryCatalog_SearchFoldsEveryPluralToItsSingular(t *testing.T) {
root := NewRootCmd("test", "abc123", "2024-01-01", "unknown")
for _, pair := range [][2]string{{"policy", "policies"}, {"patch", "patches"}, {"class", "classes"}, {"address", "addresses"}} {
singular, err := queryCatalog(root, catalogQuery{Search: pair[0]})
if err != nil {
t.Fatal(err)
}
plural, err := queryCatalog(root, catalogQuery{Search: pair[1]})
if err != nil {
t.Fatal(err)
}
if len(singular) == 0 || len(plural) != len(singular) {
t.Errorf("--search %q found %d commands and %q found %d; a plural must find what its singular finds",
pair[1], len(plural), pair[0], len(singular))
}
}
}

func TestQueryCatalog_RefusesASearchWithNoWords(t *testing.T) {
root := NewRootCmd("test", "abc123", "2024-01-01", "unknown")
for _, search := range []string{"-", "--", " / "} {
if got, err := queryCatalog(root, catalogQuery{Search: search}); err == nil {
t.Errorf("--search %q has no words and must be refused, got %d commands", search, len(got))
}
}
}

func TestCommandsCmd_ChildrenCountIsAColumnInEveryTableFormat(t *testing.T) {
for _, format := range []string{"table", "csv"} {
stdout, _, err := runRoot(t, "commands", "--children", "-o", format)
if err != nil {
t.Fatalf("commands --children -o %s failed: %v", format, err)
}
if !strings.Contains(strings.ToLower(stdout), "subcommands") {
t.Errorf("-o %s must carry a subcommands column, got:\n%s", format, stdout[:min(400, len(stdout))])
}
}

stdout, _, err := runRoot(t, "commands", "--children", "-o", "plain")
if err != nil {
t.Fatalf("commands --children -o plain failed: %v", err)
}
var sawZero, sawCount bool
for _, line := range strings.Split(stdout, "\n") {
fields := strings.Split(line, "\t")
switch last := fields[len(fields)-1]; {
case strings.HasPrefix(line, "agent-context\t"):
sawZero = last == "0"
case strings.HasPrefix(line, "pro\t"):
sawCount = last != "" && last != "0"
}
}
if !sawZero || !sawCount {
t.Errorf("-o plain must end each row with its count, 0 included, got:\n%s", stdout[:min(400, len(stdout))])
}
}
Loading
Loading