MCP server for Incus container and VM manager.
210 operations = 209 grouped + 1 ROOT: read (87), write (75), execute (9), delete (34), admin (4).
- In Authentik, create an OAuth2/OpenID provider for Incus (public client)
- Create an
app_passwordtoken: Admin → Directory → Tokens → Create (intent:app_password, user: your user, expiring: off) - Copy the token key — this goes into
INCUS_PASSWORD
{
"mcpServers": {
"incus": {
"command": "uvx",
"args": ["--refresh", "--extra-index-url", "https://nikitatsym.github.io/incus-mcp/simple", "incus-mcp"],
"env": {
"INCUS_URL": "https://incus.example.com:8443",
"INCUS_OIDC_ISSUER": "https://auth.example.com/application/o/incus/",
"INCUS_OIDC_CLIENT_ID": "your-client-id",
"INCUS_USERNAME": "your-username",
"INCUS_PASSWORD": "your-authentik-app-password-token"
}
}
}
}The server handles OIDC token exchange and refresh automatically (Resource Owner Password Grant).
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:secp384r1 \
-sha384 -keyout incus-mcp.key -out incus-mcp.crt \
-nodes -days 3650 -subj "/CN=incus-mcp"
incus config trust add-certificate incus-mcp.crt{
"mcpServers": {
"incus": {
"command": "uvx",
"args": ["--refresh", "--extra-index-url", "https://nikitatsym.github.io/incus-mcp/simple", "incus-mcp"],
"env": {
"INCUS_URL": "https://incus.example.com:8443",
"INCUS_CLIENT_CERT": "/path/to/incus-mcp.crt",
"INCUS_CLIENT_KEY": "/path/to/incus-mcp.key"
}
}
}
}- Claude Code:
~/.claude.json→mcpServers - Claude Desktop: Settings → Developer → Edit Config
- Cursor: Settings → MCP Servers
Or use the setup page to generate the config.
- Write verification. Every verifiable write is checked sent-vs-returned recursively: a silently dropped key (e.g.
config.limits.cpuaccepted then ignored by Incus) is reported as an error naming the full path instead of a phantom "201 Created". operation='schema'. Any group returns a machine-readable JSON Schema for an operation:params={"op": "CreateInstance"}.- Richer
help.operation='help'renders each parameter with its type,?for optional,T | Nonefor nullable, and a bullet with the parameter's description. _UNSETsemantics. Omitting a parameter differs from passingnull: omitted params never reach the API; an explicitnullclears a server-side value on PUT/PATCH.- Non-blocking waiters.
operation_wait_start/operation_wait_poll/operation_wait_cancel(+waits_list) poll a long-running Incus operation in the background instead of blocking the session;wait_operationstays for short one-shot waits. - Post-terminal verify on async writes. When an async write's operation finishes, the target resource is fetched and verified; a drop surfaces as
verify_erroron the wait handle (non-blocking) or as thewait_operationerror result (blocking).
| Group | Operations | Description |
|---|---|---|
incus_read |
87 | Instances, images, networks, storage, profiles, projects, cluster, certificates, operations, warnings, resources, metrics, operation waiters |
incus_write |
75 | Create/update instances, images, networks, storage, profiles, projects, cluster, certificates |
incus_execute |
9 | Instance state changes (start/stop/restart/freeze), exec commands |
incus_delete |
34 | Delete all resource types |
incus_admin |
4 | Server config, warnings management |
Each group is a single MCP tool. Call with operation="help" to list available operations, or pass operation="OperationName" with params={...}.
uv run python dev.py check runs lint (ruff + mypy --strict) and the test suite - the same command the pre-commit hook and CI use. Install the hook once:
git config core.hooksPath .githooksIntegration smoke tests hit a real Incus server and are excluded from the default run (-m integration). Provision a throwaway Incus and run them:
- Linux host or VM:
sudo scripts/e2e-env.sh && (. .e2e/env && uv run python dev.py e2e). The script installs Incus, does a minimalincus admin init, pre-pulls thee2e-alpineimage, mints a client cert, and writes.e2e/env(git-ignored) with the matchingINCUS_*vars. Idempotent - re-runs are no-ops. - macOS: Incus has no macOS daemon, so run the same recipe inside a Linux VM:
limactl start template://ubuntu-lts, then clone the repo and run the Linux recipe inside the VM. This is the environment CI uses.
CI runs the same smokes in a dedicated e2e job (.github/workflows/build.yml) that gates the release build.
tests/test_swagger_conformance.py reads every registered operation off its own AST and checks method, path, query-param names, body-field names and enum values against Incus's swagger spec - Incus ignores names it does not know, so a typo returns 200 and silently does nothing. It runs in the default gate against the copy of doc/rest-api.yaml vendored in tests/data/, since no running Incus serves that spec. Re-pin it to a newer Incus with:
uv run --with pyyaml python scripts/fetch-incus-spec.py v7.3.0