Skip to content

MCP Server

Neil Martin edited this page Oct 29, 2026 · 12 revisions

MCP Server

jamf-cli mcp serve exposes the CLI command tree to any Model Context Protocol (MCP) client: Claude Desktop, Cursor, IDE assistants, and custom agents. The server ships three tools, and the connecting model composes CLI invocations from the live command catalog, 1,766 leaves in v1.31.0.

For the curated Claude Code experience, see the Claude Code Skills plugin; mcp serve is the open transport that lets any MCP-capable client drive jamf-cli.

Requirements

  • jamf-cli v1.20.0+ installed and on your PATH
  • At least one profile configured (see Setup Guide)
  • An MCP-capable stdio client (Claude Desktop, Cursor, OpenAI Codex CLI, etc.). ChatGPT needs a bridge (see ChatGPT (needs an HTTPS bridge) below).
  • For generate_report alone: a report directory, set once with jamf-cli config set-report-dir <dir>

Transport and Tools

The server speaks JSON-RPC over stdio and registers three tools:

Tool Purpose
list_commands Browses and searches the command catalog, one JSON object per line. The model calls this first to discover what it can run. Takes two optional arguments, prefix and query (v1.32.0+). See Browsing the catalog below.
run_command Executes a jamf-cli command. The model passes the command and flags as an args array, e.g. ["pro","computers","list"] or ["pro","classic-policies","get","--name","My Policy"]. Output defaults to JSON. See Security Model for what it refuses.
generate_report (v1.31.0+) Writes a shareable HTML fleet report into the configured report directory and returns its path, size and warnings, never the HTML. Takes title, smart_groups and full. See Generating HTML reports below.

Each run_command call re-invokes the same binary as a child process, so auth, output formatting, gateway routing and version checks all run in the child's normal startup path.

That also means the whole tree is exposed as it ships: the seven leaves new in v1.30.0 (pro open, protect open, school open, security open, and pro sso-settings oidc-broker-config with its get and update) need no MCP-side change. The child's stdout is a pipe, never a terminal, so open prints the console URL instead of launching a browser. A model calling ["pro","open","policies"] gets the URL back as data, and ["pro","open","--list"] returns all 139 named sections with no credentials at all.

Browsing the catalog (v1.32.0)

Before v1.32.0, list_commands returned the whole catalog in one result. That ran past the 256 KiB cap on a child's output, so the model got invalid JSON cut off before the Protect, School and Security Cloud commands. Now the tool returns NDJSON, one row per line, each result under 40 KiB:

Arguments Result
none The top level: the five products and the core commands. A row with "subcommands": N has N commands under it
prefix One command path, e.g. "pro", then "pro computers". A runnable command carries description, destructive and flags
query The commands whose path, description or aliases contain every word, e.g. "delete policy". Add prefix to search one product. Search rows carry no flags
{"command":"completion","description":"Generate shell completion scripts","destructive":false,"flags":"","subcommands":5}
{"command":"config","description":"Manage CLI configuration and profiles","destructive":false,"flags":"","subcommands":8}

A query that matches nothing answers {"matches":0,"hint":"no command matches; use fewer or shorter words, or browse with prefix"}. A last line carrying "truncated": true means the tool left rows out; narrow the query or add a prefix. jamf-cli commands takes the same selection as --prefix, --children and --search (see CLI Patterns).

Catalog fields

list_commands rows carry command, description, destructive, flags and subcommands alone. The full record, with every field below, comes from the catalog command itself, which run_command runs: ["commands","--prefix","pro mobile-devices lock","-o","json"]. An agent can settle these before running anything:

Field Use
command, description, flags, aliases Compose a valid invocation
product, group Scope a search to one product
destructive Know in advance that --yes is required
preview Tell a Preview endpoint from a GA one. true where the spec declares x-preview on the operation; positive-only, so its absence records nothing. Thirteen commands carry it in v1.31.0: platform ai-policies × 10 and platform ai-tools × 3. A model that should not build on a changing contract reads this field rather than the Preview - prefix in description
api Know which API serves the command: pro, pro-classic, platform-gateway, or radar. It matters most under security, where the last two sit side by side and take different credentials
gateway, gatewayBasis, gatewayDetail Know whether the command is refused on the profile the server was launched with. "gateway": "unserved" means a platform gateway credential cannot reach it and the CLI refuses it before sending (exit 8); gatewayBasis and gatewayDetail carry the evidence
privileges The Jamf Pro API-role privileges a Pro command needs, or the capability permissions for a Platform command
gatewayPrivileges The Jamf Account capability permissions the gateway requires for a Pro or Classic endpoint, in a different vocabulary from privileges
gatewayPermissions The same requirement in the words Jamf Account's picker prints, e.g. Inventory > Device groups: Read (device-groups:read); the actionable form, since the picker shows no slugs. Present for Platform commands too, whose own privileges are already capability slugs
scopes The Jamf Platform API scope levels the published spec declares for a Platform command: environment, tenant, or both. A 403 names a capability permission and says nothing about the level, so this field is where the level is recorded. Reported, never enforced, since the gateway does not follow the specs everywhere

Each field is positive-only: an absent field means "nothing recorded". An absent gateway is no promise that the endpoint is served, and an absent privileges no claim that none are needed. Use the fields to skip a doomed call, and read the error when one comes back.

A refusal is also explained in the command's own --help and in the runtime error. All three name a replacement command where one ships, and the catalog carries it as gatewaySuccessor. No row carries it in v1.31.0, so the runtime message points at an instance profile instead; have the model read the refusal message.

Security Model

  • Credentials never cross the protocol. The server captures the profile it was launched with (-p/--profile or JAMF_PROFILE) and injects it into every child invocation. The connecting model sees command output alone, never tokens or secrets.

  • The server judges the command cobra resolves (v1.32.0+). Before, run_command compared the raw argument list against a deny-list, so an alias (cfg), a leading flag (--no-color multi) or a flag inside the path (pro -q backup) got past it. Now the server resolves args the way the CLI would, and the child checks again before it runs.

  • The server is pinned to its launch profile. run_command rejects any flag naming a profile, URL, token, tenant, environment or output file: --profile (and any clustered short token carrying p, such as -pProd), --include-profile, --url, --token-file, --tenant-id, --environment-id and --out-file. Each would point the child at a different instance, swap the credentials, or write to a host path. A rejected call fails with an error naming the flag.

  • The server refuses local paths unless you allow them (v1.32.0+). It refuses a flag that reads a local file (--from-file, --file, --script-file, --mobileconfig-file, --body-file, --input and the like) unless the path resolves inside the directory you pass to mcp serve --input-dir <dir>, symlinks followed. It always refuses --password-file, and every flag that writes a local path (--save-to, --report-dir, --dir on sync, a command's own --output); the -o/--output format flag stays available. pro diff accepts only this server's profile or a directory inside --input-dir as a side.

  • The server refuses body logging. -vvv, or any --verbose level of 3 or more, logs response bodies to stderr before redaction, and run_command returns stderr. Use -vv or less; its header log shows Authorization, Cookie and Set-Cookie as [redacted].

  • The server refuses some command paths, since they pick their own instance, write where the model says, or print a credential: multi, mcp, completion, the config write subcommands, config validate, doctor, every setup, both backup commands and jcds sync; every auth token, pro api-authentication token, oauth-token and keep-alive; pro sso-oauth-session-tokens; the commands that mint and print a secret (pro api-integrations client-credentials, protect api-clients apply); pro cloud-distribution-point create and patch; the commands that set a Jamf Pro login password (pro jamf-pro-user-account-settings change-password, pro accounts create, update, apply); the writes that change who can log in to a Jamf product (v1.33.0+, listed below); protect downloads csr and websocket-auth; and protect action-configs export. run_command also refuses dashboard; use generate_report. jamf-cli mcp serve --help carries the full list.

  • The server refuses the writes that change who can log in (v1.33.0+). A write that sets no password can still grant a login: a model could create a Classic administrator account, bind an account group to an LDAP server it runs, point SSO at its own identity provider, or send password-reset mail through its own SMTP server. Each login then works outside the MCP server. run_command refuses, with the reason in the error:

    • create, update and, where it exists, apply on pro classic-account-users, classic-account-groups, classic-ldap-servers, cloud-ldap and cloud-azure, and pro cloud-ldap update-mappings
    • create, update and apply on pro api-roles and pro api-integrations (v1.35.0), because a model could add privileges to the role its own integration is bound to, or bind the integration to a stronger role
    • pro classic-smtp-server update and pro smtp-server update
    • pro sso-settings update, disable, cert create, cert update and oidc-broker-config update, including the sso-settings-cert spelling
    • platform sso-connections create and update
    • apply on protect users, groups and roles, and on school users and groups
    • protect restore, refused whole, as protect backup already was, because --resources and --exclude are the model's to choose

    Reads, delete, history notes and connection tests on these resources still run. Outside MCP nothing changes. An operator who wants a model to manage logins runs the command in a terminal.

  • The server refuses pro supervision-identities download (v1.35.0). It prints the identity's .p12, private key included, to stdout, which reaches the model. get, list and delete on api-roles and api-integrations still run.

  • The server refuses -v together with --from-file (v1.35.0). -v logs each request URL, and a lookup's URL holds lines of the file. Inside --input-dir, every message about a list entry names its line N and not its value, and a failed request keeps its exit code and hint but drops its text. Outside mcp serve the messages still quote the entry.

  • The server redacts some output. config show runs with each token, client ID and client secret shown as <redacted>. Every Classic get and list shows each field Classic --set refuses as a credential as <redacted>, so a Classic -o raw over MCP is not the wire bytes. It redacts configuration profile payloads by key name, and the third-party secret in protect action-configs get, protect data-forwarding get, protect api-clients get and pro cloud-distribution-point list. The pinned tenant's own device secrets (the LAPS password, the Recovery Lock password, the FileVault personal recovery key) and blueprint configuration stay visible.

  • The credential layer is the boundary. These refusals narrow what a model can reach, and a Jamf API role still decides what it can change (see Is there a read-only mode? below).

  • Children run with --no-input. Commands that would prompt (setup, unconfirmed destructive operations) fail fast instead of hanging. The server strips any model-supplied --no-input from args and re-applies it itself.

  • Destructive commands require explicit --yes. The model passes --yes in the args array to confirm a delete or other destructive operation, or the command refuses to run. list_commands marks each one "destructive": true, so the model can tell which commands need it.

Note: the server runs against a single profile, so scope what the connecting client can touch by pointing it at a profile with limited API privileges.

Is there a read-only mode?

There is no read-only flag on mcp serve. The server exposes the whole command tree, and the built-in guardrails gate destructive operations (delete, erase) behind an explicit --yes. The server passes create, update and apply through.

For a read-only session, enforce it at the credential layer: point the server at a profile whose Jamf API role has read-only privileges. The MCP server inherits the permissions of its launch profile, so a read-only role leaves every run_command read-only however the model composes it, and Jamf Pro enforces that server-side.

# Launch the server against a profile backed by a read-only API role
jamf-cli -p my-readonly-profile mcp serve

Generating HTML reports (v1.31.0+)

generate_report writes a self-contained HTML fleet report and returns its path, size and warnings. It never returns the HTML: the document is 320 to 800 KB, far too large for a conversation, which is also why run_command refuses dashboard.

Reach for it when the administrator wants something to share or action rather than read now; run_command still answers anything a table can.

Input Meaning
title Report heading; must not begin with a dash
smart_groups Smart group names to visualize
full Collect the extended sections. Omit for the fast report

It needs a report directory

The destination and file name are server-derived and cannot be set per call, since a connecting AI must not choose a host path. Designate one once:

jamf-cli config set-report-dir ~/Reports

generate_report refuses until it is set, and mcp serve prints a hint on startup when it is missing. File names are derived from the profile and a timestamp, and the file is opened O_EXCL so a collision cannot overwrite an existing report. See Configuration & Profiles#Set the report directory (v1.31.0+).

Two tiers, and the question you should expect

Collection is two-tiered, and the tool description tells the model to run the fast tier first and ask before the full one. So expect a question like "the instance has N managed devices, would you like the full report?" rather than a long silence. What each tier costs is in Fleet Dashboard#Two collection tiers and in jamf-cli dashboard --help.

The model is told to get the fleet size with ["pro","computers-inventory","list","--limit","1","--field","totalCount"], because generate_report returns the path and size alone and never the report's own figures.

One profile per report

The report covers the profile mcp serve was launched with, and --include-profile is blocked on the MCP path, so a report cannot span two profiles here. Use the dashboard command directly for that.

A Platform profile (auth-method: platform) gives the most comprehensive single-profile report: one set of credentials authenticates against the gateway and collects both Jamf Pro data and Platform data (blueprints, compliance benchmarks, DDM reports).

Configuration

Claude Desktop

Add jamf-cli to your claude_desktop_config.json:

{
  "mcpServers": {
    "jamf-cli": {
      "command": "jamf-cli",
      "args": ["-p", "my-profile", "mcp", "serve"]
    }
  }
}

Replace my-profile with the profile name you want the client to target. Restart the client to pick up the new server.

OpenAI Codex CLI

Codex CLI is a stdio MCP client, so it drives jamf-cli mcp serve the same way Claude Desktop does. Add a server block to ~/.codex/config.toml:

[mcp_servers.jamf-cli]
command = "jamf-cli"
args = ["-p", "my-profile", "mcp", "serve"]

Or register it from the terminal (Codex writes the same config entry for you):

codex mcp add jamf-cli -- jamf-cli -p my-profile mcp serve

The tokens after -- are the launch command as you would type it. Start Codex and it discovers the list_commands and run_command tools. Codex separates stdio servers, configured with command/args as above, from remote streamable_http servers configured with a url; jamf-cli is a stdio server.

Other MCP clients

Any client that supports stdio MCP servers works the same way. Configure it to run:

jamf-cli -p my-profile mcp serve

The -p/--profile flag (or the JAMF_PROFILE environment variable) selects the instance. Omit it and the server uses the default profile resolution from Configuration & Profiles.

ChatGPT (needs an HTTPS bridge)

ChatGPT's Developer mode (available on Pro, Plus, Business, Enterprise, and Education accounts on the web) takes custom MCP servers over HTTPS with the Streamable HTTP or SSE transport. jamf-cli mcp serve speaks stdio, so ChatGPT cannot launch or connect to it the way Claude Desktop, Cursor, or Codex CLI do.

Reaching it from ChatGPT takes a stdio-to-HTTP bridge (e.g. mcp-proxy) in front of mcp serve, with that HTTPS endpoint exposed through a public tunnel such as ngrok or Cloudflare Tunnel. This is not recommended: it publishes a server holding live Jamf credentials to the public internet, and ChatGPT Developer mode itself warns that write actions "can inadvertently destroy, alter, or share data." If you pursue it, put the bridge behind authentication and point mcp serve at a read-only profile (see Is there a read-only mode? above). For an interactive assistant, use a local stdio client (Claude Desktop, Cursor, or Codex CLI).

Example Session

Once connected, the client:

  1. Calls list_commands to discover the catalog
  2. Calls run_command with ["pro","computers","list"] to list computers
  3. Calls run_command with ["pro","classic-policies","get","--name","My Policy"] to fetch a policy by name
  4. For a destructive change, calls run_command with ["pro","buildings","delete","--name","HQ","--yes"]

Output comes back as text (JSON by default). A non-zero exit reaches the model as an error result with the captured output, so the model can react to failures.

Launched against a platform gateway profile, the server refuses a Jamf Pro or Classic command outside the gateway's published API before it is sent, with exit code 8 and a message explaining why (see Error Handling & Exit Codes#Refused by Policy (exit 8)). The refusal covers everyday device actions, so a model driving MDM commands over MCP wants either an instance profile or a check on the catalog's gateway field first:

run_command ["commands","--prefix","pro mobile-devices","-o","json","--select","command,gateway"]
  →  skip rows where gateway == "unserved"

Troubleshooting

Client can't find the server. Make sure jamf-cli is on the PATH of the environment the client launches from. Use an absolute path in command if needed (e.g. /opt/homebrew/bin/jamf-cli).

Commands fail with an auth error. The profile passed in args must exist and resolve. Test it in your terminal first: jamf-cli -p my-profile pro computers list.

A command "refuses to run". Destructive commands need an explicit --yes in the args array. Setup commands cannot run over MCP, since they require interactive prompts (see the Credential Input Policy).

flag --from-file is not available over MCP (v1.32.0+). The server has no input directory, or the model named a file outside it (the message then quotes the path and the directory). Start it with one and keep the files the model may read there, using absolute paths, since a relative one resolves from the server's working directory:

"args": ["-p", "my-profile", "mcp", "serve", "--input-dir", "/Users/me/jamf-mcp-inputs"]

The directory must exist, and --input-dir "" (an unset variable, say) stops the server from starting.

Model passes a command as one string. Each command and flag is a separate array element: ["pro","computers","list"], and not ["pro computers list"].

A result that will not parse as JSON, with a warning: line at the top. run_command returns the child's combined stdout and stderr, so anything the CLI writes to stderr is prepended to the output the model gets. The usual cause is a retired pro resource name: 103 were renamed in v1.29.0, 100 still run, and each prints one line on stderr first.

warning: `icons` is a deprecated name for `icon` and stops working after 2027-03-09. Use `pro icon`.
{
  "id": 1,
  ...
}

Neither --quiet nor --no-hints suppresses that line, so use the current name. list_commands carries the current spelling for every resource, which is one more reason for the model to call it before composing anything. Full tables: Pro Command Renames.

A command fails with "exitCodeName": "unsupported" (exit 8). The launch profile's credentials cannot reach the API that serves the command. Point the server at a profile that can (an instance profile for Jamf Pro and Classic endpoints outside the gateway's published API; a platform profile for Platform API commands), or have the model skip those commands using the gateway and api fields from run_command ["commands", …, "-o", "json"].

See Also

jamf-cli Wiki


Products

  • Jamf Pro: jamf-cli pro
  • Jamf Platform API: jamf-cli pro (blueprints, benchmarks, DDM reports)
  • Jamf Platform: jamf-cli platform (AI Governance, Jamf Account, audit)
  • Jamf Protect: jamf-cli protect
  • Jamf School: jamf-cli school
  • Jamf Security Cloud: jamf-cli security

Clone this wiki locally