{
  "markdown": "# truenas-mcp\n\nAn MCP server for TrueNAS SCALE. Read-first, deployable as a TrueNAS app, and\nauthenticated with each user's own API key.\n\n> **Status: working, young.** Every capability in the design is implemented and\n> verified against a live TrueNAS 26 box. Expect rough edges rather than gaps.\n\n## Why this exists\n\niX ship an official [`truenas/truenas-mcp`](https://github.com/truenas/truenas-mcp),\nand if it fits your needs you should use it. This one exists for three things it\ndoes not do:\n\n- **It only speaks stdio**, so it cannot be deployed as a container on the NAS\n  and reached from elsewhere.\n- **It holds a single server-wide API key**, so every caller gets identical\n  reach no matter how they authenticate.\n- **Its app coverage is catalog-shaped** — install, uninstall, browse — with no\n  way to pull new images and redeploy an app you already run.\n\n## Design\n\nThree ideas do most of the work.\n\n**The credential is the authorization.** Callers supply their own TrueNAS API\nkey; this server stores none. A session reaches exactly what that user's key\npermits, revocation happens in the TrueNAS UI, and there is no shared secret to\nleak. Authentication and authorization stop being two systems that can disagree.\nServed over stdio the key arrives from the environment instead, because there is\nno request to carry it — but the client spawns one process per user, so it is\nstill that user's own key. What the design rules out is one key standing in for\nmany callers, not configuration as such.\n\n**Reads and writes get different tool shapes.** Reads are grouped into\nconcern-level tools with an `op` enum, because they share most of their\narguments and 815 middleware methods cannot each become a tool. Writes are\nindividual tools — MCP annotations are per-tool, so bundling a safe operation\nwith a destructive one behind one `op` parameter would put both behind a single\nconsent gate, and a user who tires of confirming `list_pools` will allowlist the\ntool that can also export a pool.\n\n**Read-only by default.** Mutating tools appear only when explicitly enabled.\nSeparately, a denylist of unrecoverable operations is not reachable under any\nconfiguration, and it constrains argument values rather than just method names —\ndeleting an app is recoverable, deleting it along with its volumes is not, and\nthose are the same method.\n\nEach of these was measured against a live box rather than reasoned about in the\nabstract, and several were overturned by what that measurement found.\n\n## Requirements\n\n- TrueNAS SCALE **25.04 or later**. The REST API is removed in TrueNAS 26; this\n  server speaks only the versioned JSON-RPC 2.0 WebSocket API.\n- A TrueNAS API key per user. Create them under **Credentials → API Keys**.\n\n## Deploying as a TrueNAS app\n\nCopy [`deploy/truenas-custom-app.yaml`](deploy/truenas-custom-app.yaml), adjust\n`TRUENAS_MCP_TARGET`, and paste it into **Apps → Discover → Install via YAML**.\n\nIt mounts no host socket and requests no privileged access. The server reaches\nthe middleware over the network even when running on the same box, so that every\nconnection carries a user identity rather than root-equivalent socket access.\n\n### Running it elsewhere\n\nNothing requires the server to run on the machine it manages, and there is a\ngood reason not to: installed as a TrueNAS app, it is unavailable exactly when\nthe box is unhealthy — which is when you most want to ask it what is wrong.\n\n```\ndocker run -p 8080:8080 \\\n  -e TRUENAS_MCP_TARGET=nas.local \\\n  -e TRUENAS_MCP_TLS_CERT=/tls/cert.pem \\\n  -e TRUENAS_MCP_TLS_KEY=/tls/key.pem \\\n  ghcr.io/cedricziel/truenas-mcp:main\n```\n\n### Running the binary\n\nEach [GitHub release](https://github.com/cedricziel/truenas-mcp/releases)\nattaches binaries for Linux, macOS, and Windows on amd64 and arm64, alongside a\n`checksums.txt`. Configuration is environment variables only — there is no\nconfig file, and the only flags are `--stdio` and `--healthcheck`.\n\nBy default the binary is an HTTP server. Running it does not make a client pick\nit up on its own; it listens on a port, and the client connects to it by URL.\nFor clients that spawn the server themselves, see\n[Serving over stdio](#serving-over-stdio) below.\n\n```bash\nTRUENAS_MCP_TARGET=nas.local \\\nTRUENAS_MCP_LISTEN=127.0.0.1:8080 \\\nTRUENAS_MCP_TARGET_INSECURE=true \\\nTRUENAS_MCP_ALLOW_PLAINTEXT=true \\\n./truenas-mcp\n```\n\nPoint the client at `http://localhost:8080/mcp`, with the TrueNAS API key sent\nas an `Authorization: Bearer` header, the same as in\n[Connecting a client](#connecting-a-client).\n\n`TRUENAS_MCP_TARGET_INSECURE` is typically needed for the reason given in\n[On the two TLS settings](#on-the-two-tls-settings): TrueNAS ships a\nself-signed certificate for `CN=localhost` that will not validate against any\nother address. `TRUENAS_MCP_ALLOW_PLAINTEXT` is defensible here specifically\nbecause `TRUENAS_MCP_LISTEN` binds the listener to loopback — reachable only\nfrom the same machine — which is the condition that section argues plaintext\nrequires. The default bind address is `:8080`, which is every interface, so\ndropping that setting while keeping plaintext would put API keys on the wire.\n\n### Serving over stdio\n\nSome clients spawn a server as a subprocess and talk to it over its standard\ninput and output rather than connecting to a URL. `--stdio` serves the same\ntools that way.\n\n```bash\nclaude mcp add --scope user truenas \\\n  --env TRUENAS_MCP_TARGET=nas.local \\\n  --env TRUENAS_MCP_TARGET_INSECURE=true \\\n  --env TRUENAS_MCP_API_KEY=$YOUR_TRUENAS_API_KEY \\\n  -- /path/to/truenas-mcp --stdio\n```\n\nThere is no request to carry a header here, so the key comes from\n`TRUENAS_MCP_API_KEY` instead. That is not the shared secret the HTTP transport\navoids: the client spawns one process per user, so the key it passes is that\nuser's own, and the process reaches exactly what that key permits. The same\nvariable is refused in HTTP mode, where one process serves many callers and a\nconfigured key would be shared by all of them.\n\nNothing about the listener applies. `TRUENAS_MCP_LISTEN`, the two TLS settings\nand `TRUENAS_MCP_ALLOW_PLAINTEXT` are ignored with a warning rather than an\nerror, since none of them weakens anything when no listener exists.\n`--healthcheck` is refused alongside `--stdio`, because it probes a listener\nthat was never started.\n\nSettings that concern the target rather than the listener still apply, including\n`TRUENAS_MCP_TARGET_INSECURE` and `TRUENAS_MCP_ENABLE_WRITES`.\n\n## Configuration\n\nAll configuration is environment variables; no config file or persistent volume\nis needed. Invalid configuration refuses to start rather than running degraded.\n\n| Variable | Default | Meaning |\n|---|---|---|\n| `TRUENAS_MCP_TARGET` | *required* | TrueNAS host, optionally `host:port` |\n| `TRUENAS_MCP_LISTEN` | `:8080` | Bind address |\n| `TRUENAS_MCP_TLS_CERT` / `TRUENAS_MCP_TLS_KEY` | — | Serve MCP over TLS |\n| `TRUENAS_MCP_ALLOW_PLAINTEXT` | `false` | Serve without TLS (see below) |\n| `TRUENAS_MCP_TARGET_INSECURE` | `false` | Accept the target's certificate unverified |\n| `TRUENAS_MCP_TARGET_ALLOW_PLAINTEXT` | `false` | Connect to the target without TLS |\n| `TRUENAS_MCP_ENABLE_WRITES` | `false` | Expose mutating tools |\n| `TRUENAS_MCP_API_KEY` | — | Credential for `--stdio`; refused otherwise |\n\n**No credential is configurable for the HTTP transport.** Callers supply their\nown with each request, and setting `TRUENAS_MCP_API_KEY` without `--stdio` is a\nstartup error rather than a silent fallback. Over stdio there is no request to\ncarry one and the process serves a single user, so the variable is how that\nuser's key arrives — see [Serving over stdio](#serving-over-stdio).\n\n### On the two TLS settings\n\nTransport scheme and certificate verification are deliberately separate.\n\nTrueNAS ships a self-signed certificate issued for `CN=localhost` with only\n`DNS:localhost` as a SAN, so no address you can reach it by will validate. The\nfix is `TRUENAS_MCP_TARGET_INSECURE=true`, which keeps the connection encrypted\nand merely unauthenticated. If certificate problems forced you onto plaintext\ninstead, TrueNAS would see your API key in the clear — and revoke it.\n\n`TRUENAS_MCP_ALLOW_PLAINTEXT` is about the boundary callers cross, which carries\ntheir API keys. It is correct when a reverse proxy terminates TLS in front of\nthe server, and wrong when the plaintext listener is reachable directly.\n\n## Connecting a client\n\n```bash\nclaude mcp add --scope user --transport http truenas \\\n  https://your-host/mcp \\\n  --header \"Authorization: Bearer $YOUR_TRUENAS_API_KEY\"\n```\n\nThe key may also be sent as `X-TrueNAS-API-Key`, for clients that cannot set an\n`Authorization` header. Requests without either are refused with `401`.\n\nClients that spawn the server rather than connect to one want\n[Serving over stdio](#serving-over-stdio) instead.\n\n## Current state\n\nWorking:\n\n- Streamable HTTP transport, per-session credentials, `401` without one\n- Stdio transport under a single per-process credential, for clients that spawn\n  the server rather than connect to one\n- JSON-RPC middleware client: concurrent calls on one connection, structured\n  errors distinguishing unreachable / unauthenticated / unauthorized / rate\n  limited, and interrupted requests reported as *may have been applied*\n- Session reconnection when a connection dies, and refusal to run against a\n  release older than 25.04\n- Container image, CI, GHCR publication, TrueNAS app deployment\n\n**Not implemented:** job progress via resource *subscription*. Polling covers\nthe same ground and is the path the design treats as reliable — subscription\nwas always an enhancement over it, and MCP client support for it is thin.\n\n### Tools\n\n| Tool | Operations |\n|---|---|\n| `storage` | `list_pools`, `show_pool`, `list_datasets`, `show_dataset`, `list_snapshots` |\n| `system` | `info`, `alerts`, `list_services`, `update_status`, `version`, `audit_log` |\n| `sharing` | `list_smb`, `show_smb`, `smb_acl`, `list_nfs`, `show_nfs`, `list_web` |\n| `virtualization` | `list_vms`, `show_vm`, `vm_devices`, `list_containers`, `show_container`, `container_devices` |\n| `backup` | `list_cloud_syncs`, `show_cloud_sync`, `cloud_credentials`, `list_replications`, `show_replication`, `list_rsync_tasks`, `list_snapshot_tasks` |\n| `filesystem` | `list_directory`, `stat`, `space`, `acl` |\n| `apps` | `list`, `show`, `config`, `containers`, `outdated_images`, `upgrade_summary`, `rollback_versions`, `used_ports` |\n| `catalog` | `list`, `categories`, `show` |\n| `jobs` | `list`, `show` |\n| `search_methods` | find middleware methods by name |\n| `describe_method` | a method's arguments, summarised |\n| `call_method` | invoke a method directly |\n| `server_info` | — |\n| `system_info` | — |\n\nEvery tool declares a complete MCP annotation set — `title`, `readOnlyHint`,\n`destructiveHint`, `idempotentHint`, `openWorldHint`. The spec defaults for\n`destructiveHint` and `openWorldHint` are *true*, so an unset field does not\nmean \"unknown\", it means \"assume the worst\" — and a read tool treated as\ndestructive produces prompts on safe operations, which is what teaches people\nto click through the prompts that matter.\n\nEvery method behind the read tools is\nverified against the target's own RBAC metadata to grant `READONLY_ADMIN`,\nso \"this tool cannot mutate\" is checked rather than asserted.\n\nThe `apps` operations `outdated_images`, `upgrade_summary`, and\n`rollback_versions` exist so a caller can decide *whether* to act before the\nwrite tier can act — a mutation surface without them forces the model to\nguess. All three take an app `name`; the middleware has no fleet-wide\nequivalent.\n\n`catalog` answers \"what could I install\", `apps` answers \"what is installed\" —\ndeliberately two tools rather than two operations on one, since a model\nchoosing between well-named tools does better than one choosing between\noperations on an overloaded one. `catalog list` projects down to identity and\nversion fields by default: the underlying method returns roughly 400 entries,\neach carrying a full HTML readme, config schema, and version history, so an\nunprojected browse would exhaust a caller's context an order of magnitude\nworse than the problem that motivated `apps list`'s own projection. Narrow it\nwith `category`, whose vocabulary comes from `catalog categories`; `full=true`\nstill returns everything. `catalog show` returns one entry's complete record\nby name, with no separate `catalog.get_app_details` call needed.\n\n### Write tools\n\nOff by default. Set `TRUENAS_MCP_ENABLE_WRITES=true` to expose them.\n\n| Tool | Effect | Annotated |\n|---|---|---|\n| `app_pull_images` | pull latest images and redeploy | destructive |\n| `app_redeploy` | redeploy without pulling | destructive |\n| `app_stop` | stop a running app | destructive, idempotent |\n| `app_upgrade` | upgrade to a newer version | destructive |\n| `app_rollback` | roll back a bad upgrade or pull | destructive |\n| `app_start` | start a stopped app | idempotent |\n| `create_snapshot` | snapshot a dataset | additive |\n| `create_smb_share` | share a path over SMB | additive |\n| `update_smb_share` | change an SMB share | destructive |\n| `delete_smb_share` | stop sharing over SMB | destructive |\n| `create_nfs_export` | export a path over NFS | additive |\n| `update_nfs_export` | change an NFS export | destructive |\n| `delete_nfs_export` | stop exporting over NFS | destructive |\n| `set_smb_share_acl` | who may connect to a share | destructive |\n| `set_path_acl` | filesystem permissions on a path | destructive |\n\n**Share and permission configuration is the point.** It is the hardest part of\nrunning TrueNAS and the least destructive: a misconfigured share is a support\nthread, not data loss. Handing that to an assistant is squarely what this\nserver is for.\n\nThe one genuine hazard lives in an *argument*, not a method. `filesystem.setacl`\naccepts `recursive`, `traverse`, and `stripacl` — recursive plus stripacl walks\na whole dataset discarding every ACL, which locks people out of terabytes and\ncannot be undone without knowing what the previous permissions were. All three\nare refused permanently, so setting one path's ACL stays available while the\nunbounded form does not. That distinction is the entire reason the denylist\ngates argument values rather than method names.\n\nEach is a separate tool, so each is a separate consent decision — bundling\nthem behind one `op` would put `app_stop` behind the same gate as `app_start`.\n`app_rollback` ships whenever the others do; it is the recovery path that makes\nexposing them defensible.\n\nMutations never block. They return a `job_id` immediately; follow it with\n`jobs(op=\"show\", job_id=…)`.\n\n**Denied under every configuration:** pool export, dataset deletion, disk wipe,\nboot detach, snapshot destruction — and `app.delete` with `remove_ixvolumes`,\nbecause the danger there is in the argument, not the method. None of this is\nswitchable; use the web interface.\n\n**On app logs:** TrueNAS exposes container output through the\n`app.container_log_follow` event source rather than a JSON-RPC method.\n`apps(op=\"logs\", name=...)` returns a bounded timestamped tail, not a live\nfollow. When an app has multiple containers, first call `apps(op=\"containers\",\nname=...)` and pass one returned ID as `container`.\n\n### The discovery escape hatch\n\nThe middleware has 815 methods across 74 namespaces. Most will never justify a\ndedicated tool, so `search_methods` / `describe_method` / `call_method` cover\nthe tail without a code change per release.\n\nReachability is decided by the target's **own RBAC metadata**, not by guessing\nfrom method names: a method is readable exactly when it grants\n`READONLY_ADMIN`, and mutating methods need the write tier. That is the\nmiddleware's own answer, so it is exact and tracks API versions without a\nchange here. It reaches **94% of the API** — 411 readable, 359 mutating.\n\nThe 6% withheld is deliberate:\n\n- **`core.bulk`** invokes arbitrary methods; reachable, it would bypass the\n  denylist, the write tier, and every other gate here.\n- **`auth.*`** is the server's to manage. A caller driving it could mint a\n  token that outlives the session and never appears in the API keys UI — a\n  credential the operator never issued.\n- **Methods declaring no roles at all.** On this target those are session and\n  protocol plumbing, not harmless reads, so \"no privilege check\" is treated as\n  unknown risk rather than no risk.\n\n`describe_method` summarises rather than dumps. Measured on a live target,\n`sharing.smb.create`'s schema is ~31,000 characters and\n`directoryservices.update`'s ~53,000; models also fill large sparse schemas\nless accurately than small dense ones, so a faithful dump costs more and works\nworse. Pass `full=true` when you really want it.\n\n### Resources\n\n| URI | Content |\n|---|---|\n| `truenas://alerts` | current alerts |\n| `truenas://system/health` | version, hostname, uptime, hardware |\n| `truenas://pools` | pools with capacity and health |\n| `truenas://apps` | installed apps and their state |\n| `truenas://job/{id}` | a long-running operation's progress |\n| `truenas://docs/query-filters` | filter syntax for `call_method` |\n| `truenas://docs/dataset-properties` | ZFS field meanings and inheritance |\n\nResources differ from tools by *control locus*, not cost: tools are\nmodel-controlled, resources are what a person attaches. They pay off when a\nhuman points at one — no round trip, no tool budget — and underperform when a\nmodel has to go find them, since model-driven resource access routes through\ngeneric list/read tools and reintroduces the round trips it was meant to avoid.\n\nSo: addressable entities and reference material here, anything computed or\nparameterised stays a tool. The documentation resources are the best value in\nthe design — they teach the filter syntax and ZFS semantics once instead of\nrepeating them in every tool description, where the tokens would be paid on\nevery request. A test asserts tool descriptions do not restate them.\n\n## Releases\n\nReleasing runs through [release-please](https://github.com/googleapis/release-please)\nand nowhere else. It reads the conventional-commit history on `main`, keeps a\nrelease PR open with the next version and changelog, and cutting a release is\nmerging that PR.\n\n```\npush to main ──▶ ci.yml        test, lint, publish :main and :sha-<commit>\n             └─▶ release.yml   maintain the release PR\n                                  │\n                merge PR ─────────┴─▶ tag vX.Y.Z, GitHub release,\n                                      publish :X.Y.Z :X.Y :latest\n```\n\n`ci.yml` deliberately does not react to tags, so a version tag cannot appear\nwithout a release. The release job re-runs the tests against the tagged commit\nbefore publishing — the tag is a different commit from the one CI last checked,\nand a release is only as trustworthy as the tests that gated it.\n\nEach release also attaches binaries for Linux, macOS, and Windows and a\n`checksums.txt`, alongside the container image.\n\n**Token.** Set a `RELEASE_PLEASE_TOKEN` repository secret to a PAT with\n`contents: write` and `pull-requests: write`. Without it the workflow falls back\nto `GITHUB_TOKEN`, which works but cannot trigger downstream workflows — so the\nrelease tag would not start the publish job.\n\n## Development\n\n```bash\nmake test     # unit tests\nmake lint     # go vet + golangci-lint\nmake build\nmake image\n```\n\nIntegration tests need a live TrueNAS and are excluded from `make test`:\n\n```bash\nTRUENAS_TEST_URL=wss://nas.local/api/current \\\nTRUENAS_TEST_API_KEY=... \\\nTRUENAS_TEST_INSECURE=true \\\ngo test -tags=integration ./...\n```\n\nThe behaviour this server is expected to hold to is written down as capability\nspecs rather than inferred from the code, and each scenario in them is a test\ncase in waiting.\n\n## License\n\nMIT. See [LICENSE](LICENSE).\n\nThe middleware client here is written rather than adapted from\n`truenas/truenas-mcp`, which is GPL-3.0 — that is what keeps this project's\nlicensing choice open.\n",
  "bytes": 19937,
  "sha": "350afaa82c8287369ca87690642ea3daf6d4467ee5770c1e63cb371bb0f15cd5",
  "repo_slug": "cedricziel/truenas-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_cedricziel_truenas_mcp_ccf37a18/readme"
}