Skip to main content
Glama
jackvaughanjr

jamf-platform-mcp-server

README.md
# jamf-platform-mcp-server: MCP server for the Jamf Platform API Gateway

![Tier](https://img.shields.io/badge/tier-Prototype-yellow)
![Upstream](https://img.shields.io/badge/upstream-Jamf%20Platform%20API%20(Beta)-orange)
![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen)
![Tests](https://img.shields.io/badge/tests-345%20passing-brightgreen)
![License](https://img.shields.io/badge/license-MIT-blue)
![SemVer](https://img.shields.io/badge/SemVer-2.0.0-blue)
![Keep a Changelog](https://img.shields.io/badge/changelog-Keep%20a%20Changelog-orange)
![Node](https://img.shields.io/badge/node-%3E%3D20-339933)

An MCP server that gives AI assistants read access to a Jamf fleet through the
**Jamf Platform API Gateway**, authenticating with OAuth 2.0 client credentials
rather than user-account tokens. That choice is the point of the project: scoped
machine credentials mean the permission boundary is enforced by Jamf, so a
read-only integration cannot mutate a fleet no matter what this code does. See
[JPM-0001](decisions/JPM-0001-target-platform-api-gateway.md).

A second, optional server built from the same code makes a deliberately narrow set
of reversible writes, today Restricted Software create and update, under its own
credential ([JPM-0008](decisions/JPM-0008-reversible-writes-as-a-separate-server.md)).
The read server never gains a write tool.
Canonical location: `github.com/jackvaughanjr/jamf-platform-mcp-server`.

**Tier:** Prototype — no production dependants, upstream API in public beta, minor
versions may break.

> **The upstream API is a public beta.** The Platform API Gateway has no published
> breaking-change protocol and no announced GA date. Pin dependencies and expect
> churn. One documented API group (Compliance Benchmarks) currently returns a
> gateway-side 500 ([JPM-0006](decisions/JPM-0006-classic-and-declaration-reporting-are-supported.md)).

## Provenance

**This is an independent project, not a fork or a derivative.** No shared git
history, no upstream remote, and no copied code.

It is worth naming an influence, though: evaluating
[dbankscard/jamf-mcp-server](https://github.com/dbankscard/jamf-mcp-server) (MIT)
is what surfaced the compound-tool idea used here: answering a whole fleet
question in one call rather than making a model loop over per-device requests.
That is a design idea, freely reusable and not subject to any licence term, so
this credit is courtesy rather than obligation and carries no requirement onward
to anyone using this project. Reasoning:
[JPM-0002](decisions/JPM-0002-new-repo-not-a-fork.md).

Related work worth knowing about:

- [Jamf-Concepts/mcp-hub](https://github.com/Jamf-Concepts/mcp-hub) — Jamf's own
  open-source MCP server (Python, Beta) for Jamf Pro, Protect and Security Cloud
- [`developer.jamf.com/mcp`](https://developer.jamf.com/developer-guide/docs/mcp) —
  Jamf-hosted MCP server for documentation search, not tenant management

## Structure

```
src/
  index.ts              read server: tool registration, stdio transport
  write-server.ts       write server (JPM-0008): restricted-software create/update only
  restricted-software.ts  write logic: scope rules, XML, diff, read-back, rollback
  mcp-common.ts         config loading and result rendering shared by both servers
  platform-client.ts    every gateway concern — auth, token cache, URL shapes, paging
  config.ts             environment validation (zod)
  fleet.ts              pure fleet aggregation — no client, no clock, no I/O
  automations.ts        script/policy auditing, criteria projection, inventory cost
  declaration-scope.ts  pure DDM rollup: one declaration across many devices
  references.ts         pure reference analysis + smart-group dependency graph
  adr-guard.ts          CI's ADR immutability check; allows only a superseded-by pointer
  *.test.ts             unit tests (vitest)
decisions/              architectural decision records, JPM- prefix, immutable
docs/
  capabilities.md       what the server can answer, by question rather than endpoint
  gateway-reference.md  observed gateway behaviour: paths, status semantics, paging
  endpoint-inventory.md documented endpoint surface, compiled from Jamf's llms.txt
  BACKLOG.md            open work: why each item exists and what done looks like
fixtures/
  shapes/               type-only response schemas — committed
  raw/                  captured responses — GITIGNORED, live fleet data
  discovery-report.md   empirical record of what resolves
scripts/
  discover-gateway.sh   resolves service segments; probes the PRE-2026-move gateway
  fetch-blueprints.sh   standalone Blueprints smoke test (current gateway)
  jamf                  cwd-independent wrapper: scripts/jamf <tool> ['<json>']
  call-tool.mjs         calls one MCP tool live; inherits env so `op run` works
  check-adr-immutability.sh
.githooks/pre-commit    rejects force-added ignored files; enforces ADR immutability
```

## Current state (as of 2026-10-06)

**Jamf moved the gateway between 2026-08-10 and 2026-10-06.** The host is now
`{region}.api.jamfcloud.com`, paths drop the `/api/` prefix and the tenant segment,
and the tenant or environment travels in a header. Pre-move integrations did not
survive; create a **Platform environment** integration. Details in
[docs/gateway-reference.md](docs/gateway-reference.md#since-the-2026-move-confirmed-2026-10-06).

Working and confirmed against a live tenant, re-confirmed on the new gateway
2026-10-06 (path below the host is `/{segment}/{version}/{resource}`, or
`/proclassic/{resource}`):

| segment | style | resource | notes |
|---|---|---|---|
| `blueprints` | tenant | `blueprints` | `totalCount`-only envelope; needs a Platform environment integration |
| `blueprints` | tenant | `blueprint-components` | records keyed `identifier`, not `id` |
| `devices` | tenant | `devices` | full paging envelope; spans macOS **and** iOS |
| `device-groups` | tenant | `device-groups` | full envelope; exposes `memberCount` |
| `pro` | tenant | 300+ resources | the Jamf Pro API in full |
| `proclassic` | classic | `{resource}` | Jamf Pro Classic — no version segment |
| `ddm/report` | tenant | `devices/{id}/channels` | Declaration Reporting |
| `ddm/report` | tenant | `devices/{id}/declarations` | per-device declaration state; `filter` required |
| `ddm/report` | tenant | `declarations/{id}/devices` | the same state per declaration, across devices |

**Compliance Benchmarks** has a correct, documented path but returns 500
`{"error":"Upstream host lookup failed"}`. The gateway routes it and cannot reach
its own backend. A fault on Jamf's side, not something a client can work around.

An earlier revision of this file claimed Classic, Declaration Reporting and
Compliance Benchmarks were simply not exposed. That was wrong; see
[JPM-0006](decisions/JPM-0006-classic-and-declaration-reporting-are-supported.md),
which supersedes JPM-0005 and explains how the error happened.

Tools: `getFleetOverview`, `findDevices`, `findOutdatedDevices`, `findDeviceGroups`,
`getDeviceGroupMembers`, `findExpensiveAutomations`, `getInventoryCollectionSettings`
and `findCriteriaReferences` (compound), `listBlueprints` and
`getDeviceDeclarationState`, `getDeclarationScope`, `findObjectReferences` and
`findGroupDependencies` and `getComputerGroup` (typed), and
`platformRequest` (authenticated **GET-only** passthrough to any gateway route). Tool
count stays deliberately small
([JPM-0003](decisions/JPM-0003-passthrough-plus-selective-typed-tools.md)).

Write server: `createRestrictedSoftware` and `updateRestrictedSoftware`, nothing else.
Its read path is confirmed live against real entries; **a live write has not yet been
made through it**, so the XML request body, which Jamf's pages do not publish, is
still unverified. Open work is tracked in [docs/BACKLOG.md](docs/BACKLOG.md).

Pagination is confirmed live: a real page-1 request returned different records with
`hasPrevious: true` and `totalPages: 13`, so `page` is 0-based as assumed and query
parameters survive the passthrough.

`device-actions` remains unverified, because every route in it is a write and its
scopes are never granted (JPM-0007, unchanged by JPM-0008).

## Setup

```bash
npm install                    # also points core.hooksPath at .githooks
cp .env.op.example .env.op     # edit to match your 1Password vault/item
npm run build
```

Create an integration in **Jamf Account → Integrations** at the **Platform
environment** level, choosing the environment that contains your Jamf Pro tenant. A
read-only integration is sufficient and strongly preferred. The client secret is
shown exactly once. The level cannot be changed later, and the legacy **Tenant**
level cannot reach Blueprints.

| Variable | Required | Notes |
|---|---|---|
| `JAMF_CLIENT_ID` | yes | from the integration |
| `JAMF_CLIENT_SECRET` | yes | shown once at creation |
| `JAMF_ENVIRONMENT_ID` | one of these two | Platform environment integration; sent as `X-Environment-Id` on every request |
| `JAMF_TENANT_ID` | one of these two | legacy Tenant integration; sent as `X-Tenant-Id`; cannot reach Blueprints |
| `JAMF_GATEWAY_BASE_URL` | no | defaults to `https://us.api.jamfcloud.com` |
| `JAMF_TOKEN_URL` | no | defaults to `<base>/auth/token` |
| `JAMF_READ_ONLY` | no | defaults to `true`; a backstop, **not** the guarantee |

### Write posture

A read-only integration (above) is the read server's supported configuration, not
a starter mode to graduate from
([JPM-0007](decisions/JPM-0007-write-path-posture.md)). Writes live in a separate
server under a separate credential (below). Scopes that can erase or
unmanage a device are never granted to this server — not gated, not granted, in
any configuration. That work belongs in Jamf Pro's own interface, where it is
attributed to a named person and lands in Jamf's audit log.

`JAMF_READ_ONLY` above is a backstop, not the guarantee: it is on unless the value
is the literal string `false`, so a typo fails closed. The real boundary is what
the credential's scopes permit, which Jamf enforces, not this code. A fork that
grants write scopes is making that decision, and owns what follows from it.

`platformRequest` offers no `method` or `body` parameter, so the passthrough cannot
express a mutation at all, not even with write scopes granted. A passthrough write
is unreviewable in a way a typed tool's write is not, since method, path and body
would all be caller-composed with no schema constraining any of them. Every
write is a named tool with a narrow schema, so the set of possible mutations stays
enumerable by reading one file.

### The write server

Reversible writes ship as a **second server** built from this repo,
`dist/write-server.js`
([JPM-0008](decisions/JPM-0008-reversible-writes-as-a-separate-server.md)). It
registers only `createRestrictedSoftware` and `updateRestrictedSoftware`: no
passthrough, no read tools, and no delete. The read server registers no write
tools in any configuration.

- **Its own integration.** A Platform environment integration named for its role
  (it is expected to grow), granted exactly what the write tools need. Today that is
  `restricted-software:read`, `:create` and `:update`, and never `:delete`. Each new
  write tool adds its permissions here, alongside its ADR. Keep it in its own
  1Password item, separate from the read integration, so the read server's credential
  can never write.
- **Its own env file**, with `JAMF_READ_ONLY=false`. With the flag on, the server
  still starts and dry runs still work, but every real write refuses.
- **Registered per project, not globally.** Register it only in the repository that
  holds your Jamf change log, so its tools appear only there. Local scope keeps the
  machine paths out of that repository; run this from inside it:

  ```bash
  claude mcp add -s local jamf-platform-write -- \
    op run --env-file=/absolute/path/to/.env.op.write -- \
    node /absolute/path/to/dist/write-server.js
  ```

  Consider an `ask` permission rule for `mcp__jamf-platform-write` in that
  repository's `.claude/settings.local.json`, so every write prompts even in
  accept-edits mode.

Both tools default to `dryRun: true`, which returns the exact XML and a
field-by-field diff and writes nothing. A real write reads the entry back and reports
any field Jamf stored differently, and returns rollback arguments. Scope is required
on create and never defaults to all computers. An update changes only the fields
passed and leaves scope alone unless a complete replacement is given.

Credentials are injected at runtime so the secret never lands on disk:

```bash
op run --env-file=.env.op -- npm run dev
```

### Register with Claude Code

```bash
claude mcp add jamf-platform -- node /absolute/path/to/dist/index.js
```

Register the read server globally if you like; it cannot write. The write server is
registered per project (see above).

## Conventions

- **ISO dates** (`YYYY-MM-DD`) everywhere, including in dated snapshots above.
- **ADRs are immutable** once committed. Correct one by superseding it, never by
  editing. Enforced by `scripts/check-adr-immutability.sh` via the pre-commit
  hook; `ADR_ALLOW_EDIT=1` covers the sanctioned exceptions. Prefix: **`JPM-`**.
- **Decisions vs findings.** `decisions/` holds decisions and is immutable.
  `docs/gateway-reference.md` holds observations about a beta API and is expected
  to change. Do not mix them.
- **Never `git add -f`.** The ignore list is a data-handling boundary guarding the
  client secret and captured fleet data; a pre-commit hook enforces it.
- **Never commit a captured API response.** Only type-only shapes
  ([JPM-0004](decisions/JPM-0004-type-only-fixtures.md)).
- **Confirm routes empirically.** A Jamf docs section is not evidence a route exists.
  The documentation has been wrong about this gateway four times, each retraction
  recorded in [docs/gateway-reference.md](docs/gateway-reference.md).
- **Commit messages** explain *why*, and state explicitly when they retract an
  earlier conclusion. Descriptive imperative subjects; not Conventional Commits,
  which is why there is no badge claiming otherwise.

## Versioning

[SemVer 2.0.0](https://semver.org/spec/v2.0.0.html). `package.json` is the single
source of the version. Changes are recorded in
[CHANGELOG.md](CHANGELOG.md) per [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Releases are tagged `vX.Y.Z`.

While the gateway remains in public beta, **minor versions may carry breaking
changes**, because the upstream contract offers no stability guarantee, so strict SemVer
against it would be a false promise.

## Testing

```bash
npm test              # vitest, 345 tests
npm run typecheck
DRY_RUN=1 ./scripts/discover-gateway.sh    # probe matrix (pre-move gateway shapes; see the script header)
```

To exercise a tool against a live tenant, from any directory:

```bash
scripts/jamf tools/list
scripts/jamf getFleetOverview
scripts/jamf findDevices '{"query":"MacBook"}'
```

`scripts/jamf` wraps `op run` with an absolute `--env-file` path, because `op`
resolves that against the caller's cwd and fails with a bare "open .env.op: no such
file or directory" otherwise. It also unsets `OP_SERVICE_ACCOUNT_TOKEN`, which the
committed `.envrc` handles inside the repo but cannot outside it, and resolves
symlinks so it can be linked onto `PATH` for a shorter handle:

```bash
ln -s "$PWD/scripts/jamf" ~/.local/bin/jamf-mcp
jamf-mcp getFleetOverview
```

**Not `npm run inspector` under `op run`.** The MCP Inspector spawns the server as
a child process without forwarding the parent environment, so injected credentials
never reach it and the server exits on config validation. Its `-e` flag would work
but puts the client secret on a command line where `ps` can read it.
`scripts/call-tool.mjs` spawns the server with the environment inherited, so
credentials go straight to the process that needs them. Its output can contain live
fleet data, so redirect to a gitignored path if you keep it.

Tests never reach the gateway: `fetch` is stubbed per test and credentials are
fixtures. The suite is mutation-checked rather than assumed useful: each of these
deliberate breakages causes failures: removing the `totalCount` pagination
fallback, making paging 1-based, disabling the read-only guard, misclassifying
iPads as Macs, treating an unparseable timestamp as a recent check-in, letting an
empty search query match every device, and counting an absent `managed` flag as
unmanaged.

## The contribution contract

[CONTRIBUTING.md](CONTRIBUTING.md) is written as a contract, separating what a guard
will stop you doing from what a human reviews. Three rules are mechanically enforced
by a pre-commit hook and by CI:

| Rule | Enforced by |
|---|---|
| No live identifiers in tracked files — including test fixtures | `scripts/check-no-identifiers.sh` |
| No captured API responses committed; never `git add -f` | `.githooks/pre-commit` |
| Committed ADRs are immutable — supersede, never edit (a Status-only superseded-by pointer is the one allowed edit) | `scripts/check-adr-immutability.sh` + `src/adr-guard.ts` in CI |

`src/conventions.test.ts` additionally asserts the conventions that drifted during
early development: the test-count badge matches reality, ADR numbering is sequential
and fully indexed, every superseded record names its successor, and the README does
not cite a superseded ADR as guidance.

Test UUIDs use the reserved `deadbeef-` prefix, so an identifier copied out of live
output is visible rather than plausible. That rule exists because a real device id
reached a test file exactly that way.

## Pull request and review policy

Single maintainer at present, so changes land directly on `main`. On a second
contributor: branch protection on `main`, one non-author approval, and
`decisions/` changes reviewed by someone other than the author.

Every change should pass `npm test`, `npm run typecheck`, and `npm run build`.
Anything touching `src/platform-client.ts` should also be exercised against a live
tenant, since no test can confirm the gateway's actual behaviour.

## Cross-reference

- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, enforced rules, how to add an endpoint
- [CLAUDE.md](CLAUDE.md) — working rules for AI assistants in this repo
- [decisions/](decisions/) — why the project is built this way
- [docs/gateway-reference.md](docs/gateway-reference.md) — observed gateway behaviour
- [docs/capabilities.md](docs/capabilities.md) — what the server can answer, and what it cannot
- [docs/endpoint-inventory.md](docs/endpoint-inventory.md) — documented endpoint surface
- [fixtures/discovery-report.md](fixtures/discovery-report.md) — empirical results

## License

MIT — see [LICENSE](LICENSE).

TDQS

A4.1/5.0

Scored across 15 tools

Disambiguation5/5

Each tool targets a distinct resource and action: device search, group search, DDM state, scope, references, dependencies, etc. Even the two declaration tools are clear inverses, and the passthrough is explicitly read-only, so there is no ambiguity about which tool to select.

Naming Consistency4/5

Tool names are consistently camelCase and mostly follow a verb+noun pattern, but the verbs vary (list, get, find) and one tool (platformRequest) breaks the pattern. This is a minor deviation, not chaotic, and the meaning of each verb is reasonably intuitive.

Tool Count5/5

15 tools is well-scoped for a broad platform server. Each tool covers a meaningful slice of the Jamf surface, and the count feels neither thin nor bloated.

Completeness2/5

The server is read-only by design — there are no create, update, or delete tools, and the generic passthrough explicitly refuses writes. This leaves a critical gap for a management platform: agents can inspect the entire fleet but cannot perform any management action, which will cause failures for any workflow requiring a change.

Maintenance

ActivityMaintained
ResponsivenessNo issues