termix-mcp
termix-mcp
An MCP server for Termix, the self-hosted SSH and server-management platform. It gives an MCP client (Claude Code, Claude Desktop) the ability to list hosts, run commands, read and write files, control Docker and systemd, manage tunnels, and read Termix's own logs — modeled on the XPipe MCP, with a proper SSH session manager and full audit logging.
What it can do
36 tools, grouped:
Group | Tools |
Hosts |
|
Command execution |
|
Files |
|
Docker |
|
System |
|
Tunnels & snippets |
|
Termix observability |
|
Access policy |
|
Meta |
|
run_command deserves a note: Termix has no direct run-a-command endpoint, so it
is implemented by writing a temporary script to the host through the file manager,
making it executable, executing it (Termix's executeFile returns the captured
output), and deleting it. It therefore runs in a non-login shell with a minimal
environment — set any variables you need explicitly.
Safety model
Mutations are disabled by default. Every tool that changes state (
run_command,write_file,service_action,docker_container_action, …) refuses until the client callstoggle_state { enabled: true }for the session. Read tools always work. This mirrors the read-only-by-default posture of the XPipe MCP.Host blocklist.
TERMIX_BLOCKLISTis a comma list of Termix host names and/or IPs that this server refuses to touch for reads and writes alike. Empty by default — it names machines in your estate, so there is no sane value to ship. Matching is exact and case-insensitive (soappblocks a host namedappwithout also blockingapp-main). Blocklisted hosts still appear inlist_hosts, tagged"blocked": true.Secrets never leave Termix. File-manager and Docker sessions are opened by host id; Termix resolves the stored SSH credentials server-side. This server never handles SSH passwords or keys.
The
call_apiescape hatch is contained. It reaches the ~240 endpoints with no dedicated tool, but it is not a way around the rules: the blocklist is applied to any host id in the path, query, or body; non-GET counts as a mutation; endpoints that hand out credentials or mint access are refused outright (/host/db/host/{id}/password,/credentials,/vault,/users/api-keys,/users/me/token, host exports,/host/quick-connect,/guacamole, RBAC sharing); and session-opening endpoints are refused so a session can only be created through a tool that checked the blocklist first. A stateful call must present a session this server opened.
Per-profile access control
A single global blocklist cannot express "this token may touch these machines."
Set TERMIX_POLICY_FILE to a JSON policy defining profiles, each scoping:
Scope | Behaviour |
Machines | Which hosts the profile may reach. Matches a host's name or IP, |
Read vs write |
|
Tools | Which tools it may call, by name, glob, or |
Paths | Which paths the file tools may touch. Canonicalized first, so |
Global blocklist |
|
Choosing the scope: modes
Each dimension carries its own mode, so you decide how strict it is rather than accepting a posture baked into the server:
Mode | Meaning |
| Default deny. Permits what |
| Default allow. Refuses what |
| No restriction on this dimension. |
| Nothing passes. |
The list that names the mode is the intent; the other list is the exceptions to it — which is why the tie-breaker flips with the mode.
Omit mode and it is inferred: allowlist if you wrote allow, blocklist if
you wrote only deny, otherwise the dimension default (hosts allowlist,
tools and paths all). Shorthand is accepted anywhere a rule set is:
"hosts": "all", // a mode on its own
"hosts": ["app-main", "web-proxy"], // array = allowlist of these
"tools": { "mode": "blocklist", "deny": ["@escape"] }Tool groups
So a policy need not enumerate 34 names — miss one and the restriction has a
hole. @readonly @hosts @files @files.read @files.write @exec
@docker @system @tunnels @snippets @observability @meta @policy
@escape. Run node scripts/policy.mjs groups to list members. An unknown
group is an error, never a silently empty rule, and the test suite asserts the
groups still match the real tool set.
See access-policy.example.json for five worked profiles.
Generating and checking a policy
The format is designed to be written by a model and verified before it is
trusted. access-policy.schema.json is the JSON Schema; from a session, two
read-only tools close the loop:
access_policy_schema— returns the schema, the modes, the group definitions, the real tool names, and your live host inventory, so a draft refers to machines and tools that actually exist.access_policy_check— validates a candidate policy without applying it and reports what each profile would permit: reachable hosts, per-tool and per-path decisions, and lint warnings.
Neither writes anything; installing a policy stays a deliberate human act. The same checks are available from the shell:
node scripts/policy.mjs init > access-policy.json # starting template
node scripts/policy.mjs validate access-policy.json # exits non-zero if invalid
node scripts/policy.mjs explain access-policy.json --profile ops \
--host app-main,legacy-box --path /etc/shadow,/opt/appdata/x
node scripts/policy.mjs groups # group members and modesUse node scripts/policy.mjs rather than npm run policy when redirecting to a
file — npm prints its banner to stdout and would corrupt the output.
A profile is selected by the bearer token on the HTTP transport (so each
token gets its own access), or by TERMIX_PROFILE on stdio. A session may
only be reused by the profile that created it. A profile may also carry its own
termixApiKey, in which case Termix's native host ownership enforces the same
restriction at the source — a bug here cannot then grant what Termix denies.
Tokens and keys can be given inline, as tokenSha256, or as tokenEnv /
termixApiKeyEnv naming an environment variable — so the policy file itself can
be committed alongside your compose config while the secrets stay in the env
file.
Enforcement lives in the gate and in resolveAllowed, the one function every
host-targeting path already goes through, so a new tool cannot reach a forbidden
machine by forgetting to ask.
Path rules only bind the file tools. A shell command reads and writes wherever the SSH user can, so a path-restricted profile that still grants
run_command,find_file,run_snippet, orcall_apiis not actually restricted. The server logs a warning at startup when a policy does this.
Prompt injection is the threat that matters
Everything this server reads — a file, a container log, a terminal transcript —
flows into the model's context, and the model can run shell commands. So content
on a managed host can, in principle, instruct the agent. toggle_state is a lock
the model itself holds the key to: nothing stops it calling
toggle_state { enabled: true } except its own judgement.
If nobody is watching the session, set TERMIX_ALLOW_TOGGLE=false. That pins
the server read-only for its whole life — writes then require an operator
changing the deployment config, not a model changing its mind. Locking writes
back down at runtime always works; only enabling them is blocked.
What the blocklist does and does not protect
The blocklist is a guardrail, not a security boundary. It stops this server
from addressing a forbidden host. It cannot stop a command that reaches one by
another route: run_command on a Proxmox node can pct stop a blocklisted
container, and a command on any host can SSH onward. Anyone with run_command
and writes enabled effectively has the access the target host's credentials
carry. Treat the blocklist as protection against mistakes, not against a
determined agent — and use a Termix account whose stored credentials only reach
what you are willing to have touched.
Logging
App log: pino JSON. In stdio mode it goes to stderr (stdout is the MCP channel) or to
LOG_FILE.Audit log: every tool call is appended to
audit.jsonl(underDATA_DIR) as one JSON line — tool, target host, redacted arguments, result summary, exit code, duration, success/error, the SSH sessions used, and the mutation state at the time. Refusals are recorded too, naming the host that was attempted, so a denied attempt on a blocklisted host leaves a trace. Command output is recorded as byte counts plus a SHA-256 rather than stored, so the record can be checked against a transcript without becoming a copy of everything the agent read. Opening and closing an SSH session are their ownsession.open/session.closeevents, since a session outlives the call that created it. Written0600, fsynced per record, size-capped per line, and rotated by size.Wazuh (optional): set
WAZUH_ENABLED=trueto also emit each audit record as an RFC 5424 syslog line with an@cee:JSON payload over UDP. UDP is lossy and unauthenticated; for a durable feed, prefer a Wazuh agent tailingaudit.jsonlwithlog_format jsonover the TLS agent channel:<localfile> <log_format>json</log_format> <location>/data/audit.jsonl</location> </localfile>
Configuration
Copy .env.example and fill in TERMIX_BASE_URL and TERMIX_API_KEY. Create the
API key in Termix under User Profile → API Keys; the key's user must own the
hosts you intend to manage. Every variable and its default is documented in
.env.example. The most important ones:
Variable | Default | Purpose |
| (required) | Origin nginx serves the Termix API from |
| (required) | Bearer key from the Termix UI |
|
| Start with writes on |
| (empty) | Hosts to refuse, by name or IP |
| (required for HTTP) | Bearer guarding the |
|
| UDP syslog forwarding |
If the Termix backend services are not unified behind one origin, set the
per-service TERMIX_URL_* overrides.
Running
stdio (workstation)
npm install
TERMIX_BASE_URL=https://termix.example TERMIX_API_KEY=... npm run start:stdioRegister it with an MCP client. For Claude Code on Windows, add to the project
entry in ~/.claude.json under mcpServers:
"termix": {
"command": "node",
"args": ["C:\\path\\to\\termix-mcp\\src\\index-stdio.mjs"],
"env": {
"TERMIX_BASE_URL": "https://termix.example.com",
"TERMIX_API_KEY": "<key>",
"DATA_DIR": "C:\\path\\to\\termix-mcp\\data"
}
}streamable-HTTP (container)
The container runs the HTTP transport. deploy/docker-compose.yml is the
source-of-truth compose. It needs an env_file holding TERMIX_API_KEY and
MCP_HTTP_TOKEN. Clients connect to http://<host>:<port>/mcp with
Authorization: Bearer <MCP_HTTP_TOKEN>. GET /healthz is unauthenticated for
the container probe.
Nothing about one deployment is baked into that file. Two variables shape the
compose itself, read from the .env beside it:
Variable | Default | Purpose |
| (placeholder) | The image to run. Set it to what your CI publishes |
|
| Address the published port binds to. Set it to this host's LAN address to reach the server from elsewhere |
Everything the server itself reads — including TERMIX_TRUSTED_PROXIES, which
you want set to your reverse proxy — goes in the env_file, not in the compose
environment: block. Compose's environment: overrides env_file:, so a
${VAR:-} entry there silently blanks the env file's value whenever the
variable is missing from the .env beside the compose.
MCP_BIND_ADDR defaults to loopback on purpose: publishing a port that can run
commands on real servers should be a decision, not a default. Bind an explicit
address rather than 0.0.0.0 so a re-IPed host fails loudly instead of quietly
listening somewhere unexpected.
Verifying against a live instance
TERMIX_BASE_URL=... TERMIX_API_KEY=... npm run verify-live
# add --host <id|name> to also run the command-execution probe on a hostThis confirms auth and the base URL, enumerates hosts (flagging blocklisted ones,
never touching them), and — with --host — proves that executeFile still
captures output on your Termix version.
Development
npm install
npm test # unit + integration (in-process mock Termix), no networkReleasing
Two equivalent pipelines ship with the repo — use whichever forge you host on.
Keeping both costs nothing: GitHub reads only .github/workflows, and Forgejo
prefers .forgejo/workflows when it is present.
GitHub | Forgejo | |
Workflow |
|
|
Registry |
| the Forgejo instance itself |
Credential | the built-in | a |
Setup | none | add the secret |
On GitHub nothing needs configuring: the workflow publishes to GHCR with the token Actions already provides. Note that a package published this way starts private — make it public from the package's settings page if you want others to pull it.
On Forgejo the registry is taken from a REGISTRY repository variable, then
a REGISTRY repository secret, then the forge the workflow runs on — first
non-empty wins. Set one of the first two to the public name your registry is
reachable at, e.g. git.example.com.
Two things make this fiddlier than it looks, and both are why the fallback chain exists:
Some forges do not populate the
varscontext. An unset variable yields an empty string rather than an error, so a variable you did set can silently read as blank. If that happens, set the same value as a secret instead — secrets are carried reliably. Its value is masked in the run log.GITHUB_SERVER_URLis the internal origin on a proxied instance — an address and port serving plain HTTP, not the public name. Pushing there fails withserver gave HTTP response to HTTPS clientunless every daemon that pulls has aninsecure-registriesentry. The resolve step warns when the host it picked carries a port, which is the tell.
Both behave identically. On every push to main they install, test, and publish
:main plus an immutable :sha-<short> image. On a v* tag they additionally
verify that package.json and the tag agree and that CHANGELOG.md has a
matching ## <version> section, then publish :latest and create the release
with that changelog section as its body, attaching docker-compose.yml,
env.example, and a source tarball. Both guards fail the build before an image
is pushed, so a release can never ship with notes that say nothing or a version
that lies about itself. The source tarball comes from git archive of the commit,
not the workspace, so an untracked .env or audit.jsonl physically cannot be
shipped in it.
To cut a release:
# 1. add a "## 0.2.0" section to CHANGELOG.md describing what changed
# 2. set the same version in package.json
git commit -am "release: 0.2.0" && git push
git tag v0.2.0 && git push origin v0.2.0backfill-changelog.yaml — manual (workflow_dispatch) repair pass for
releases published before their changelog entry existed, or whose entry was
edited afterwards. It rewrites existing release bodies from CHANGELOG.md using
the same builder the release job uses, so a backfilled release is byte-identical
to a freshly cut one. Dry run by default — run it with apply: false first
to see what it would change, then apply: true to write. It patches only the
body; tags, target commits, and attached assets are untouched.
On Forgejo both jobs need a PACKAGERUNNER_TOKEN repository secret with package
and release write scope. Forgejo's automatic Actions token cannot write packages
(unauthorized: reqPackageAccess), and the FORGEJO_/GITHUB_ secret-name
prefixes are reserved, which is why it carries this name. The GitHub workflow
needs no equivalent — GITHUB_TOKEN with packages: write covers it.
Verified live against Termix 2.6.0, and against 2.7.0 and 2.7.1 by source
comparison — every endpoint this server calls still exists at the same path,
and the executeFile, changePermissions, readFile and writeFile handlers
are byte-identical across 2.6.0, 2.6.1, 2.7.0 and 2.7.1. 2.7.0 changed one
behaviour that matters here: deleteItem moves to ~/.termix-trash unless the
request sets permanent, which this server now always does for its own temp
files. 2.7.1 adds exactly one endpoint (POST /credentials/{id}/duplicate,
already refused by call_api's /credentials rule) and removes or renames
none; its handler changes are confined to SSH connect-time key validation and
jump-host errors, both in shapes this server already parses. Two upstream
behaviours shifted, visibly but harmlessly: hosts with metrics polling disabled
are now SSH-probed each status cycle, so host_status can say online where
2.7.0 said reachable (Termix itself now generates periodic SSH logins on
those hosts — expect auth-log noise), and a failed connect through a jump host
can take ~65 s per hop server-side instead of ~30 s, which only matters when
TERMIX_TIMEOUT_MS is raised past its 20 s default. Run verify-live against
2.7.1 to confirm it on a live instance.
The API can drift between versions; if a Termix upgrade changes the
executeFile, connect, or readFile response shapes, re-run verify-live
and check the run_command path. A Termix release that adds an endpoint
reaching a shell also needs a look at FORBIDDEN in src/termix/api-policy.mjs,
since call_api is guarded by a denylist. Licensed Apache-2.0.