Skip to main content
Glama
l3gitpanda

termix-mcp

by l3gitpanda

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

list_hosts, host_status, host_metrics

Command execution

run_command (captures output), run_snippet

Files

read_file, list_files, get_file_info, find_file, write_file, create_file, create_directory, delete_item, move_item, copy_item, change_permissions

Docker

docker_containers, docker_container_info, docker_container_action

System

list_services, service_action, list_processes, process_signal

Tunnels & snippets

list_tunnels, tunnel_action, list_snippets

Termix observability

audit_logs, session_logs, recent_activity, alerts

Access policy

access_policy_schema, access_policy_check

Meta

call_api, toggle_state, help

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.

Related MCP server: Python MCP Toolkit Server

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 calls toggle_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_BLOCKLIST is 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 (so app blocks a host named app without also blocking app-main). Blocklisted hosts still appear in list_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_api escape 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, * wildcard, case-insensitive.

Read vs write

"mutations": "deny" pins the profile read-only. toggle_state cannot lift it.

Tools

Which tools it may call, by name, glob, or @group.

Paths

Which paths the file tools may touch. Canonicalized first, so /opt/appdata/../../etc/shadow is refused.

Global blocklist

"globalBlocklist": "enforce" (default) or "ignore", so TERMIX_BLOCKLIST is a default for most profiles rather than a ceiling none can be granted past.

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

allowlist

Default deny. Permits what allow matches; deny carves exceptions out of the grant. Fails closed — a host added later is unreachable until granted.

blocklist

Default allow. Refuses what deny matches; allow carves exceptions back in. Fails open.

all

No restriction on this dimension.

none

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 modes

Use 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, or call_api is 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 (under DATA_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 own session.open / session.close events, since a session outlives the call that created it. Written 0600, fsynced per record, size-capped per line, and rotated by size.

  • Wazuh (optional): set WAZUH_ENABLED=true to 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 tailing audit.jsonl with log_format json over 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

TERMIX_BASE_URL

(required)

Origin nginx serves the Termix API from

TERMIX_API_KEY

(required)

Bearer key from the Termix UI

TERMIX_MUTATIONS_ENABLED

false

Start with writes on

TERMIX_BLOCKLIST

(empty)

Hosts to refuse, by name or IP

MCP_HTTP_TOKEN

(required for HTTP)

Bearer guarding the /mcp endpoint

WAZUH_ENABLED

false

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:stdio

Register 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

MCP_IMAGE

(placeholder)

The image to run. Set it to what your CI publishes

MCP_BIND_ADDR

127.0.0.1

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 host

This 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 network

Releasing

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

.github/workflows/release.yml

.forgejo/workflows/release.yaml

Registry

ghcr.io/<owner>/<repo>

the Forgejo instance itself

Credential

the built-in GITHUB_TOKEN

a PACKAGERUNNER_TOKEN secret

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 vars context. 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_URL is the internal origin on a proxied instance — an address and port serving plain HTTP, not the public name. Pushing there fails with server gave HTTP response to HTTPS client unless every daemon that pulls has an insecure-registries entry. 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.0

backfill-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.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers