terok-compute-mcp
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@terok-compute-mcprun nvidia-smi on the hal target"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
computeMCP gateway
Secure host-side gateway and single MCP bridge that let AI agents running inside Terok containers use remote development/compute containers without ever receiving the host's SSH credentials.
OpenCode / agent
| MCP over stdio
v
computeMCP-mcp (inside Terok)
| authenticated HTTP over the one allowed host endpoint
v
computeMCP-gateway (on the Terok host)
| ssh -N -L tunnels (owned by the gateway)
+--> agent@hal development container
+--> agent@fwk394 development containerThe gateway TOML is the single source of truth for targets. A client can only name a configured target; an arbitrary SSH hostname is never accepted.
Layout
src/compute_mcp/
config.py TOML loading + validation, transport selection
auth.py constant-time bearer auth and per-client ACLs
tunnel.py asyncio SSH tunnel manager, route failover, recovery
ssh_backend.py AsyncSSH: exec, PTY sessions, SFTP, host-key pinning
sessions.py persistent PTY session manager (bounded buffers, quotas)
files.py SFTP file operations
gateway.py state machine, HTTP API, interactive console
enrollment.py unauthenticated request queue + operator approval
handshake.py computeMCP-handshake: request access from inside a container
mcp_server.py MCP server (stdio) exposing computeMCP_* tools
control.py computeMCP-gatewayctl operator CLI
tests/ unit tests (118)
config.example.toml
systemd/computeMCP-gateway.serviceRelated MCP server: mcp-sandbox-server
Install (gateway on the host, not as root)
python3 -m venv ~/.local/share/computeMCP-gateway/venv
~/.local/share/computeMCP-gateway/venv/bin/pip install -U pip
~/.local/share/computeMCP-gateway/venv/bin/pip install .
ln -s ~/.local/share/computeMCP-gateway/venv/bin/computeMCP-gateway ~/.local/bin/computeMCP-gateway
mkdir -p ~/.config/computeMCP-gateway
cp config.example.toml ~/.config/computeMCP-gateway/config.tomlCreate a dedicated gateway-to-container key (never the user's normal key):
ssh-keygen -t ed25519 -f ~/.ssh/computemcp_container -C computeMCP-gateway
# install only the .pub half in the remote development containers, as the
# `authorized_keys` of the target `user` (default "agent"):
ssh-copy-id -i ~/.ssh/computemcp_container.pub agent@<container-host>Replace every
/home/USERplaceholder.config.example.tomluses/home/USER/...so it is obviously a template; a target that keeps the literal stringUSER(e.g.client_key = "/home/USER/.ssh/...") parses fine but everyexec/session fails withSSH connection to target '<t>' failed: [Errno 2] No such file or directory. Set the real absolute path forclient_key,token_file, etc.
Create the remote development container
The gateway needs a persistent SSH server to dial. Any host with Docker/Podman
and an OpenSSH sshd works; a plain ubuntu:24.04 container with
openssh-server is enough and is the setup the gateway was verified against.
The container is intentionally minimal — the agent installs its own toolchain
later — and persistent (no --rm).
Recipe (adapted from the HAL remote-development handoff,
agent-config/terok/hal-remote-development-handoff.md). Run this on the
remote host, after generating ~/.ssh/computemcp_container on the gateway
host (previous step). Paste only the public key; never copy the private key.
The HOST_HOME bind mount is what makes the toolchain persistent across
container recreation; its uid:gid is reused for the agent user.
mkdir -p ~/workspace/computeMCP-container
bash <<'BASH'
set -euo pipefail
CONTAINER_NAME="computeMCP-container"
HOST_HOME="$HOME/workspace/computeMCP-container"
SSH_PUBLIC_KEY='REPLACE_WITH_COMPUTEMCP_CONTAINER_PUBLIC_KEY' # .pub half only
if docker container inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
echo "STOP: $CONTAINER_NAME already exists; it was not modified."
exit 1
fi
if [ ! -d "$HOST_HOME" ]; then
echo "STOP: home directory not found: $HOST_HOME"
exit 1
fi
# A persistent home is intentionally allowed to be non-empty on recreate.
if [ -n "$(find "$HOST_HOME" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "NOTE: $HOST_HOME is not empty; reusing it (toolchain preserved)."
fi
AGENT_UID="$(stat -c %u "$HOST_HOME")"
AGENT_GID="$(stat -c %g "$HOST_HOME")"
printf 'Container home will use %s (uid:gid %s:%s)\n' "$HOST_HOME" "$AGENT_UID" "$AGENT_GID"
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
--device /dev/dri:/dev/dri \
--gpus all \
-p 127.0.0.1:2222:22 \
--mount "type=bind,src=$HOST_HOME,dst=/home/agent" \
-e "SSH_PUBLIC_KEY=$SSH_PUBLIC_KEY" \
-e "AGENT_UID=$AGENT_UID" \
-e "AGENT_GID=$AGENT_GID" \
ubuntu:24.04 \
bash -euc '
# This Cmd re-runs on every container start, so every step must tolerate
# already-existing state; otherwise `bash -euc` aborts before `exec sshd`
# and `--restart unless-stopped` loops forever.
if ! command -v sshd >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server sudo
fi
# Exactly one user named "agent" owns AGENT_UID. ubuntu:24.04 already ships
# a user at uid 1000 ("ubuntu"); AGENT_UID is the host-home owner (usually
# 1000), so that uid is normally already taken. Reuse (rename) the existing
# account instead of `useradd -o`, which would create a SECOND user sharing
# the uid and make the SSH login resolve to the wrong name. Renaming keeps
# the same uid/gid, so the bind-mounted home (owned by AGENT_UID) stays owned
# by the login user.
if ! id -u agent >/dev/null 2>&1; then
existing="$(getent passwd "$AGENT_UID" | cut -d: -f1 || true)"
if [ -n "$existing" ]; then
usermod -l agent "$existing" # rename the existing account
grp="$(getent group "$AGENT_GID" | cut -d: -f1 || true)"
{ [ -n "$grp" ] && [ "$grp" != "agent" ] && groupmod -n agent "$grp"; } || true
else
getent group agent >/dev/null || groupadd --gid "$AGENT_GID" agent
useradd --uid "$AGENT_UID" --gid "$AGENT_GID" \
--home-dir /home/agent --no-create-home --shell /bin/bash agent
fi
usermod -d /home/agent agent # point home at the bind mount
fi
# Passwordless sudo for "agent" and the numeric uid, so it works whichever
# name AGENT_UID resolves to (defense in depth after the rename).
for u in agent "#${AGENT_UID}"; do
printf "%s ALL=(ALL) NOPASSWD:ALL\n" "$u" \
> "/etc/sudoers.d/computemcp-$(printf "%s" "$u" | tr -c "A-Za-z0-9" "_")"
done
chmod 440 /etc/sudoers.d/computemcp-*
visudo -c
install -d -m 700 -o agent -g agent /home/agent/.ssh
printf "%s\n" "$SSH_PUBLIC_KEY" > /home/agent/.ssh/authorized_keys
chown agent:agent /home/agent/.ssh/authorized_keys
chmod 600 /home/agent/.ssh/authorized_keys
printf "PasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\nPubkeyAuthentication yes\n" \
> /etc/ssh/sshd_config.d/00-computemcp.conf
mkdir -p /run/sshd
ssh-keygen -A
# Start sshd even if the key check above warned; a real failure then shows
# up in `docker logs` instead of crash-looping.
/usr/sbin/sshd -t || true
exec /usr/sbin/sshd -D -e
'
BASHNotes and invariants:
Bind the SSH port to loopback only (
127.0.0.1:2222), never0.0.0.0. The gateway reaches it through its ownssh -N -Ltunnel; the container port must not be exposed publicly.Persistence:
--restart unless-stoppedand the bind-mountedHOST_HOMEkeep the toolchain andagenthome across container recreation. Never recreate the container merely to restart it.Install
ssh-keygen -Abefore first boot (as above): the container needs its own host keys, and the gateway pins that key.GPU flags are creation-time only.
--device,--gpus,--group-add,--security-optand-pare frozen inHostConfig;stop/startcannot add them. To change devices later, snapshot (docker commit --change 'CMD ["/usr/sbin/sshd","-D","-e"]' computeMCP-container computeMCP-container-snapshot), rename the old container, and re-run under the same name — never delete it blindly. Keep the image tag free of an environment suffix.Driver policy: the NVIDIA kernel driver lives on the host and MUST NOT be installed inside the container. Verify with
docker exec computeMCP-container nvidia-smi.The
agentuser,sudowithout password, and key-only auth (no passwords, no root login) match the gateway's default target (user = "agent").Single
agentuser, no duplicate uid. The entrypoint reuses (renames) the base account that already ownsAGENT_UIDinstead ofuseradd -o-ing a second one, so the SSH login andwhoamiboth resolve toagentrather than the stockubuntu. Passwordless sudo is granted to bothagentand the numeric#<uid>so it keeps working whichever nameAGENT_UIDresolves to, and the whole config is checked withvisudo -c.The entrypoint must be idempotent. Docker stores the
bash -euc '...'as the containerCmdand re-runs it on every start. The first version of this recipe installed the packages and created the user unconditionally, so on the second startgroupadd/useraddfailed,set -eaborted beforeexec sshd, and--restart unless-stoppedcrash-looped forever. The guards above (command -v sshd,getent group,id -u) make each step a no-op when it already ran.ubuntu:24.04already owns uid/gid1000:1000(the stockubuntuuser).AGENT_UID/AGENT_GIDcome from the host home's owner (often1000), so that uid/gid is usually already taken. The entrypoint therefore renames the existingAGENT_UIDaccount toagent(keeping the same uid/gid) instead of adding a duplicate; if you wantagentto have its own distinct uid, setAGENT_UID/AGENT_GIDto a free range in thedocker run -elines andchown -Rthe existingHOST_HOMEto match.For the next step, the sshd the gateway pins is this container's — read it from inside (
/etc/ssh/ssh_host_ed25519_key.pub) or scan the loopback port.
Recreating a container changes its host key. A freshly created container
generates new sshd host keys, so the host_key_sha256 pinned in the gateway
config becomes stale and connections fail with
Host key is not trusted for host. After every recreate, read the new
fingerprint and update the target:
docker exec computeMCP-container ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
# -> SHA256:... (use this as [targets.<name>] host_key_sha256)
computeMCP-gatewayctl --config ~/.config/computeMCP-gateway/config.toml target-refresh <name>AMD / HIP (ROCm) container variant
The gateway is GPU-agnostic (it only tunnels SSH), so an AMD target needs no
gateway change. Only the dev container recipe differs: replace the NVIDIA
--device /dev/dri:/dev/dri --gpus all with the ROCm device- and group-access
flags. --gpus (NVIDIA runtime) does not expose an AMD GPU; ROCm needs the
KFD and DRM character devices plus the video/render groups.
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
--device /dev/kfd --device /dev/dri \
--security-opt seccomp=unconfined \
--group-add video --group-add render \
-p 127.0.0.1:2222:22 \
--mount "type=bind,src=$HOST_HOME,dst=/home/agent" \
-e "SSH_PUBLIC_KEY=$SSH_PUBLIC_KEY" \
-e "AGENT_UID=$AGENT_UID" \
-e "AGENT_GID=$AGENT_GID" \
ubuntu:24.04 \
bash -euc '
# This Cmd re-runs on every container start, so every step must tolerate
# already-existing state; otherwise `bash -euc` aborts before `exec sshd`
# and `--restart unless-stopped` loops forever.
if ! command -v sshd >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server sudo
fi
# Exactly one user named "agent" owns AGENT_UID. ubuntu:24.04 already ships
# a user at uid 1000 ("ubuntu"); AGENT_UID is the host-home owner (usually
# 1000), so that uid is normally already taken. Reuse (rename) the existing
# account instead of `useradd -o`, which would create a SECOND user sharing
# the uid and make the SSH login resolve to the wrong name. Renaming keeps
# the same uid/gid, so the bind-mounted home (owned by AGENT_UID) stays owned
# by the login user.
if ! id -u agent >/dev/null 2>&1; then
existing="$(getent passwd "$AGENT_UID" | cut -d: -f1 || true)"
if [ -n "$existing" ]; then
usermod -l agent "$existing" # rename the existing account
grp="$(getent group "$AGENT_GID" | cut -d: -f1 || true)"
{ [ -n "$grp" ] && [ "$grp" != "agent" ] && groupmod -n agent "$grp"; } || true
else
getent group agent >/dev/null || groupadd --gid "$AGENT_GID" agent
useradd --uid "$AGENT_UID" --gid "$AGENT_GID" \
--home-dir /home/agent --no-create-home --shell /bin/bash agent
fi
usermod -d /home/agent agent # point home at the bind mount
fi
# Passwordless sudo for "agent" and the numeric uid, so it works whichever
# name AGENT_UID resolves to (defense in depth after the rename).
for u in agent "#${AGENT_UID}"; do
printf "%s ALL=(ALL) NOPASSWD:ALL\n" "$u" \
> "/etc/sudoers.d/computemcp-$(printf "%s" "$u" | tr -c "A-Za-z0-9" "_")"
done
chmod 440 /etc/sudoers.d/computemcp-*
visudo -c
install -d -m 700 -o agent -g agent /home/agent/.ssh
printf "%s\n" "$SSH_PUBLIC_KEY" > /home/agent/.ssh/authorized_keys
chown agent:agent /home/agent/.ssh/authorized_keys
chmod 600 /home/agent/.ssh/authorized_keys
printf "PasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\nPubkeyAuthentication yes\n" \
> /etc/ssh/sshd_config.d/00-computemcp.conf
mkdir -p /run/sshd
ssh-keygen -A
# Start sshd even if the key check above warned; a real failure then shows
# up in `docker logs` instead of crash-looping.
/usr/sbin/sshd -t || true
exec /usr/sbin/sshd -D -e
'The same guards, host-key generation and persistence notes as the NVIDIA recipe apply. Additional AMD-specific points:
--security-opt seccomp=unconfinedis effectively required: the default Docker seccomp profile blocks theioctlsROCm needs (kfd/amdgpu), causingHSA_STATUS_ERRORor "no GPU"./dev/kfdis the ROCm compute device;/dev/dri(renderD*,card*) is the DRM/render device. Both are needed;--gpus allalone is not enough on AMD.Group access: the container process must be in the host's
videoandrendergroups to open those devices.--group-add video --group-add renderresolves the names against the container's/etc/group; if the host GIDs differ (common on non-Ubuntu hosts, andrendermay be absent in the image), add the numeric host GIDs instead:--group-add "$(getent group video | cut -d: -f3)" \ --group-add "$(getent group render | cut -d: -f3)"Driver policy (AMD): the
amdgpu/ ROCm kernel driver lives on the host and MUST NOT be installed inside the container. The baseubuntu:24.04image ships no ROCm user space — the agent installs the matching ROCm userspace toolchain later, exactly as it installs CUDA toolkits for an NVIDIA target. Once installed, verify withdocker exec computeMCP-container rocminfo(androcm-smi), analogously tonvidia-smi.Creation-time only: as with the NVIDIA flags,
--device,--group-addand--security-optare frozen inHostConfig; they cannot be added bystop/start. Use the snapshot/rename/recreate procedure above.
Obtain the container host-key fingerprint
For a tunnelled target, set host_key_sha256 to the fingerprint of the sshd the
gateway dials — the development container's sshd (remote_port, usually
2222), not the host's own sshd on port 22. A known_hosts file cannot be used
reliably here because a tunnel maps an ephemeral local port.
Run this on the host, using an SSH alias that actually reaches the container:
# If the alias drops you into the container, read its own host key:
ssh <alias> 'cat /etc/ssh/ssh_host_ed25519_key.pub' | ssh-keygen -lf - | awk '{print $2}'
# Otherwise, have the alias scan the port-2222 sshd on its own loopback:
ssh <alias> 'ssh-keyscan -t ed25519 -p 2222 127.0.0.1 2>/dev/null' \
| ssh-keygen -lf - | awk '{print $2}'
# => SHA256:l/zxYjSYKwDVXFzcUwD/buQoCiznnL+eMZaxL7GsNEEBeware of entries for the plain hostname in ~/.ssh/known_hosts: an unqualified
hostname means port 22, so its fingerprint is usually the wrong key. Look
for an explicitly port-qualified entry ([host]:2222) instead:
grep -i '<container-host>' ~/.ssh/known_hosts
ssh-keygen -lf ~/.ssh/known_hostsBecause a DNS name may only resolve on one network (e.g. a company LAN) and not
another (home/VPN), put both routes in ssh_targets so the gateway can fail
over: the first alias is tried, then the next. The pin is route-independent —
every route reaches the same container sshd, so one fingerprint covers them all.
Example configuration
The full annotated file is config.example.toml; a
minimal working ~/.config/computeMCP-gateway/config.toml is:
[server]
# Bind where Terok/Podman reaches the host (often host.containers.internal).
# Keep it loopback unless containers must connect from another address.
listen = "127.0.0.1"
port = 2222
exec_timeout = 900.0
[ssh]
internal_port_min = 31000
internal_port_max = 31999
[sessions]
idle_timeout = 3600.0
max_per_client = 16
[auth]
# Filled in by `--generate-tokens` (hashes only; keep plaintext out of it).
token_file = "/home/USER/.config/computeMCP-gateway/tokens.toml"
# Each Terok project gets its own token (the hash comes from the token file)
# and an explicit list of the targets it may use. "*" means every target.
[clients.alpaka]
label = "alpaka CI"
targets = ["hal", "fwk394"]
[clients.picongpu]
label = "PIConGPU"
targets = ["hal"]
[clients.admin]
label = "operator"
targets = ["*"]
# A target reached through an SSH tunnel. `ssh_targets` are aliases from the
# gateway user's SSH config, tried in order.
[targets.hal]
ssh_targets = ["hal", "ex_hal"]
remote_host = "127.0.0.1"
remote_port = 2222
user = "agent"
client_key = "/home/USER/.ssh/computemcp_container"
# Mandatory: pin the container host key (see "Configuration notes").
host_key_sha256 = "SHA256:REPLACE_WITH_CONTAINER_HOST_KEY_FINGERPRINT"
host_key_algorithms = ["ssh-ed25519"]
auto_connect = true
# A second target, reached without a tunnel (gateway co-located with the
# container, or a test endpoint). No `ssh_targets` => transport = "direct".
[targets.hal-direct]
transport = "direct"
remote_host = "host.containers.internal"
remote_port = 2222
user = "agent"
client_key = "/home/USER/.ssh/computemcp_container"
known_hosts = "/home/USER/.ssh/known_hosts"
# Another tunnelled target, used by the `alpaka` client above.
[targets.fwk394]
ssh_targets = ["fwk394", "ex_fwk394"]
remote_host = "127.0.0.1"
remote_port = 2222
user = "agent"
client_key = "/home/USER/.ssh/computemcp_container"
host_key_sha256 = "SHA256:REPLACE_WITH_CONTAINER_HOST_KEY_FINGERPRINT"After editing, generate token hashes, then start the gateway. When the files
live in the conventional location, --config and --generate-tokens can be
omitted:
computeMCP-gateway --generate-tokens # -> ~/.config/computeMCP-gateway/tokens.toml
computeMCP-gateway # -> ~/.config/computeMCP-gateway/config.tomlPath resolution. --config defaults to
$XDG_CONFIG_HOME/computeMCP-gateway/config.toml (or
~/.config/computeMCP-gateway/config.toml); an explicit --config always
wins. --token-file is resolved in this order: the flag, then
[auth] token_file, then a tokens.toml sitting next to the config if it
exists. A missing explicit token file is an error; a missing conventional
one is not (inline client tokens may still be in use). Create the config
directory once with mkdir -p ~/.config/computeMCP-gateway.
Generate project tokens
Tokens are generated by the gateway, never hand-written. A per-project token is
issued once; only its sha256 hash is stored on the gateway. The plaintext is
printed once for you to inject into that project's Terok environment.
# writes hashes to the default tokens.toml (chmod 600) and prints the tokens
computeMCP-gateway --generate-tokens
# or point somewhere else explicitly:
computeMCP-gateway --config /path/config.toml --generate-tokens /path/tokens.tomlPoint the gateway at that file with [auth] token_file = "..." (or
--token-file). If neither is set, a tokens.toml next to config.toml is
used automatically. Then, in each Terok container set the matching plaintext token:
COMPUTEMCP_GATEWAY=http://host.containers.internal:2222
COMPUTEMCP_TOKEN=<that project's generated token>The token is the only credential the container holds; it is never the host SSH key and never committed to a repository.
Interactive enrollment with computeMCP-handshake
Instead of pre-generating a token and copying it around, a container can ask for
one. Run computeMCP-handshake inside the Terok container; it queues a request,
waits while you approve it on the gateway console, receives the token, and wires
it into the container:
computeMCP-handshake picongpu-bot-dev2 --port 2223 --system hal,fwk394
# --system is a comma-separated allow-list; omit it for an empty ACL
# (no targets) or pass --system '*' for all targets.Flow:
container gateway (host)
computeMCP-handshake <client> --port 2223 console:
│ POST /v1/enroll {client_id,targets} gateway> enrollments
├──────────────────────────────────────────► REQUEST ... CLIENT ... TARGETS
│ (request queued, pending operator) gateway> approve <request-id>
│ GET /v1/enroll/<id> (poll) → mint token, append
│◄──────── {status: approved, token} ────── [clients.<id>] + tokens.toml,
└ writes COMPUTEMCP_* to ~/.bashrc reload
and prints the MCP env snippetThe /v1/enroll request is unauthenticated but grants nothing — it only
places a bounded, expiring entry in a queue. Nothing is created until an
operator approves it (console approve <id>, or
computeMCP-gatewayctl approve <id> with an admin token). On approval the
gateway appends [clients.<id>] to config.toml, writes the token hash to
tokens.toml, reloads, and hands the plaintext token to the requester exactly
once; a poll secret proves ownership of the request.
Container-side writing:
Default: an idempotent, marker-delimited block in
~/.bashrc(re-running replaces it).--env-file ~/.config/computeMCP/env: write the token to that file (0600) and only add asourceline to~/.bashrc, keeping the secret out of the shell history file.--no-write: don't touch any file; just print the MCPenvironmentsnippet.The running opencode will not see new shell variables (a long-lived tmux/session manager keeps its old environment); paste the printed
environmentblock into the MCP entry, or restart from a fresh shell.
Turn it off with [server] allow_enrollment = false. enroll_ttl (default
600 s) and enroll_max_pending (default 32) bound the unauthenticated surface.
Allow the gateway in the Terok Shield
Terok Shield is default-deny: a task container cannot open a TCP connection to
the gateway until the project explicitly allows the destination. If the MCP
fails with a connection error even though the gateway is healthy, this is the
cause. Add the gateway host to the project's project.toml:
shield:
allow:
- localhost:2222
override:
- host: 10.0.2.2
reason: Temporary access to the HAL development tunnel
expires: 2027-10-06override.hostmust be the gateway host as the container sees it — the addresshost.containers.internalresolves to inside the task (commonly10.0.2.2; verify withgetent hosts host.containers.internal). It is not a host interface address, so do not try to bind it on the host.The
overridegrants access to that exact gateway address (all ports to that address, not a specific port); set a short, futureexpiresand remove it when no longer needed.A new task must be created after changing the Shield config; an existing task does not pick up later project changes.
Adjust the port (
2222above) to match[server] portin the gateway config.
Install the MCP inside a Terok container
python3 -m venv /home/dev/.local/share/computeMCP/venv
/home/dev/.local/share/computeMCP/venv/bin/pip install <this-package>
ln -s /home/dev/.local/share/computeMCP/venv/bin/computeMCP-mcp /home/dev/.local/bin/computeMCP-mcpConfigure the task environment. The MCP process reads two variables at start:
COMPUTEMCP_GATEWAY=http://host.containers.internal:2222
COMPUTEMCP_TOKEN=<project-specific-token>Pass them either through the environment or, more robustly, directly in the MCP entry:
{
"mcp": {
"compute": {
"type": "local",
"command": ["/home/dev/.local/bin/computeMCP-mcp"],
"enabled": true,
"environment": {
"COMPUTEMCP_GATEWAY": "http://host.containers.internal:2222",
"COMPUTEMCP_TOKEN": "<project-specific-token>"
}
}
}
}Prefer the explicit environment block: an export in ~/.bashrc (or a
login-shell config) does not reliably reach an already-running agent/TUI
because a long-lived tmux/session manager keeps its original environment.
Setting the variables in the MCP entry makes the token independent of shell
inheritance. The token then lives in opencode.json; keep that file out of any
repository and treat it as a secret, or inject it from a file the MCP reads, and
never commit it.
The MCP does not need SSH credentials and never learns hal, ex_hal,
ProxyJump aliases or internal forward ports.
Agent skill
A ready-to-use skill that teaches an AI agent how to drive the compute MCP
(discover systems, run commands, parallel sessions, file transfer) ships in
skills/computeMCP/SKILL.md. Install it
where the agent loads skills, for example:
mkdir -p ~/.config/opencode/skills/computeMCP
cp skills/computeMCP/SKILL.md ~/.config/opencode/skills/computeMCP/SKILL.mdRunning opencode run remotely through computeMCP_exec may need </dev/null:
stdin is a non-TTY pipe there, and opencode run can wait on it until EOF. The
skill covers the symptom and the workaround.
Run the gateway
# interactive (operator console); uses the default config location
computeMCP-gateway
# headless (systemd)
computeMCP-gateway --no-console
# or point at an explicit file
computeMCP-gateway --config /path/to/config.tomlConsole commands: targets, status [target], connect, refresh,
reconnect, stop, connect-all, stop-all, reload, clients,
client <name>, client-refresh|connect|stop <name> [target],
client-kill <name>, sessions [target], close-session <id>, quit.
reload fully parses and validates the TOML before replacing the active
configuration; on failure the old configuration is retained. Unchanged
connected targets keep their tunnels, removed targets are stopped, and changed
targets are marked needs_refresh. reload also re-reads the token file, so
adding or rotating a project token is a reload away.
Operator terminal: refresh config and manage API keys
Under systemd the gateway runs --no-console, so use the
computeMCP-gatewayctl operator CLI. It talks to the running gateway's
authenticated API (nothing needs to be stopped or restarted) and reads the
plaintext token from [auth] token_file or COMPUTEMCP_TOKEN.
Refresh the configuration
# re-read config.toml + tokens.toml on the running gateway
computeMCP-gatewayctl --config ~/.config/computeMCP-gateway/config.toml reload
# under systemd, equivalent and cleaner:
systemctl --user reload computeMCP-gateway # sends SIGHUPsystemctl --user reload sends SIGHUP; the daemon re-parses the config and
token file without dropping live tunnels. A malformed file is logged and the
previous configuration is kept.
See each API key (Terok client), its systems and live sessions
$ computeMCP-gatewayctl --config config.toml clients
CLIENT LABEL TARGETS SESSIONS
alpaka alpaka CI hal 1
picongpu PIConGPU hal-dedicated 0
admin operator * 0
$ computeMCP-gatewayctl --config config.toml client alpaka
client: alpaka
label: alpaka CI
token fp: sha256:dfa2d0e94f1f # fingerprint only; the token is never shown
allow_all: False
targets: hal
sessions: 1
4rF8_FtwECBBLc_Vmq4WzRp4 target=hal idle=3.2Activate / refresh / stop connections for one API key
# activate every target this key may use
computeMCP-gatewayctl --config config.toml client-connect alpaka
# refresh (re-run route failover) only that key's targets
computeMCP-gatewayctl --config config.toml client-refresh alpaka
# or a single target of that key
computeMCP-gatewayctl --config config.toml client-refresh alpaka hal
computeMCP-gatewayctl --config config.toml client-stop alpakaOnly targets inside the key's ACL are touched; anything else is refused.
Other options
computeMCP-gatewayctl --config config.toml status
computeMCP-gatewayctl --config config.toml target-connect hal
computeMCP-gatewayctl --config config.toml target-refresh hal
computeMCP-gatewayctl --config config.toml target-stop hal
computeMCP-gatewayctl --config config.toml sessions # all clients (admin)
computeMCP-gatewayctl --config config.toml client-kill alpaka # close its sessions
computeMCP-gatewayctl --config config.toml --json clients
computeMCP-gatewayctl --config config.toml --client alpaka clients # ACL-checkedclients, client* and the all-clients sessions view require an admin
(targets = ["*"]) token; other clients receive 403. Token values and full
hashes are never returned — only a short sha256: fingerprint.
Add a human label to any client so the listings are readable:
[clients.alpaka]
label = "alpaka CI"
targets = ["hal", "fwk394"]Configuration notes
ssh_targetsare SSH config aliases used in priority order. The gateway runsssh -N -o ExitOnForwardFailure=yes ... -L 127.0.0.1:<port>:<remote>and keeps the first working route. Only TOML values are ever passed tossh.transport = "tunnel"(default whenssh_targetsis present) forwards toremote_host:remote_portthrough the host.transport = "direct"connects straight toremote_host:remote_port(useful for tests or a co-located gateway) and skips the SSH tunnel.Internal forward ports always bind
127.0.0.1and are allocated from[ssh] internal_port_min..internal_port_max.Host-key verification is on by default. A target must set either
host_key_sha256orknown_hosts; otherwise the connection is refused. Because a tunnel maps an ephemeral port, fingerprint pinning viahost_key_sha256is the recommended mode. Restricthost_key_algorithms(e.g.["ssh-ed25519"]) to the pinned key's algorithm when a server offers several.host_key_check = "off"explicitly disables host-key verification and accepts any host key;host_key_algorithms(if set) then only restricts negotiation and no longer verifies the server's identity. It is only safe when the forwarded endpoint itself is trusted (e.g. a single-user dev box); never use it on a host where another user could hijack the forwarded port. A warning is logged for every such target on connect. Bothhost_key_checkandhost_key_algorithmsare optional.[targets.fwk388] ssh_targets = ["fwk388", "ex_fwk388"] user = "agent" client_key = "/home/USER/.ssh/computemcp_container" host_key_check = "off" # accept any host key (trusted dev box only)connect_modecontrols concurrency per target:"shared"(default): all exec/sessions multiplex over one cached SSH connection. Fast, but bounded by the remote sshd'sMaxSessions(OpenSSH default 10) for simultaneous channels."dedicated": each exec/session opens its own SSH connection and closes it afterward, so theMaxSessionslimit no longer bounds total parallel sessions. Costs one handshake per operation.
Interactive (second-factor) authentication: set
interactive_auth = truewhen the development container asks for a password or a keyboard-interactive challenge (OTP/2FA) instead of accepting the key alone. The gateway prompts on the operator console duringconnect,refresh, and the firstexec/ session. Passwords are read without echo; up to 3 attempts are allowed. Run the gateway in the foreground console for this to work. Headless (systemd) operations on such a target fail with a clear503error rather than hanging, because there is no one to prompt. Only the container hop is prompted; the hostssh -N -Ltunnel must stay key/agent-based.[targets.hal] ssh_targets = ["hal", "ex_hal"] user = "agent" interactive_auth = true host_key_sha256 = "SHA256:..."Console transcript:
gateway> connect hal [hal] Password: {'name': 'hal', 'state': 'connected', ...}sharingdocuments whether a system is dedicated or shared, so agents can judge benchmark reliability.computeMCP_targets()/computeMCP_status()return it:"exclusive"— dedicated to this task (e.g. a whole Slurm allocation); benchmarks are meaningful."shared"— other users/jobs may run concurrently; timings can be noisy."unknown"— not declared (default).
[targets.rosi5] ssh_targets = ["rosi5"] sharing = "exclusive" # Slurm allocation is oursnode_infois an optional list of free-form, operator-provided, unstructured hints about the underlying system (for example"x86 CPU"or"AMD GPU"). There is no fixed schema or meaning; agents treat them as starting hypotheses and verify the actual hardware. Unset or empty means no extra information was provided, so the agent discovers the system itself or the user guides it.computeMCP_targets()/computeMCP_status()return it.[targets.rosi5] ssh_targets = ["rosi5"] sharing = "exclusive" # Slurm allocation is ours node_info = ["x86 CPU", "AMD GPU"]agentis an optional ordered list of remote AI agents this target can delegate work to, such as a test run or an implementation. Each entry is a table with exactlyagentandmodel, both non-empty strings (spaces are allowed in both). The list order is the priority order: the caller should try the entries in order and fall back to the first working one. Unset or empty means no remote agent is configured.computeMCP_targets()/computeMCP_status()return it as a list of{"agent": ..., "model": ...}objects. Remote agents do not have the Terok skills, so bring their results back for local review under the Terok rules. This config field is unrelated to the SSHuser = "agent"account name.[targets.rosi5] ssh_targets = ["rosi5"] sharing = "exclusive" # Slurm allocation is ours agent = [ { agent = "opencode", model = "GWen 3.5" }, { agent = "codex", model = "Sole" }, ]HPC / Slurm targets: a fixed login node can be reached with
proxy_jump, while the dynamic compute node is discovered at connect time by a trustedprovision_command(see the next subsection).
HPC / Slurm: dynamic compute nodes
On an HPC system the login node is fixed, but the development container runs in
a Slurm job on a compute node whose name (and the forwarded port) only exist
once the job starts. The gateway supports this with provision_command, a
trusted script that acquires the node and prints the endpoint to dial.
How the pieces connect:
compute-gateway (host)
| ssh -N -L 127.0.0.1:<local>:127.0.0.1:2200 rosi5 (your local key)
v
rosi5 login node
| ssh -N -L 127.0.0.1:2200:<compute-node>:2222 <compute-node> (login node's key)
v
<compute-node> -> 127.0.0.1:2222 (development container sshd)provision_command does the middle step (submit/wait for the job and create the
login-node forward) and prints 127.0.0.1:2200; the gateway then dials it
through ssh_targets.
Config:
[targets.rosi5]
user = "agent"
client_key = "/home/USER/.ssh/computemcp_container"
host_key_sha256 = "SHA256:..." # pin of the container host key
ssh_targets = ["rosi5"] # SSH config alias of the login node
provision_command = ["/home/USER/.config/computeMCP-gateway/rosi5-provision.sh"]
provision_timeout = 900.0The contract (what the script must do):
Ensure a Slurm job is running for this target (reuse or submit).
Ensure a listener
127.0.0.1:<port>exists on the login node that forwards to the container's SSH port on the compute node.Print that endpoint as
host:porton stdout (an optionalENDPOINTprefix and other log lines are fine — the first validhost:portline wins).
The minimal possible script, useful for testing the wiring:
#!/bin/sh
# minimal contract example: just print the endpoint the gateway can dial
printf 'ENDPOINT 127.0.0.1:2200\n'A realistic self-started-job script (operator-written; a later mode can let the
gateway own sbatch by changing only this script):
#!/usr/bin/env bash
set -euo pipefail
LOGIN="${TEROK_LOGIN:-rosi5}" # ssh alias of the login node
JOB_NAME="${TEROK_JOB_NAME:-terok-dev}"
CONTAINER_PORT="${TEROK_CONTAINER_PORT:-2222}" # container's sshd port on the node
LOGIN_FORWARD_PORT="${TEROK_FORWARD_PORT:-2200}"
ALLOC="${TEROK_SBATCH_ARGS:---nodes=1 --time=08:00:00}"
# 1. Reuse a running job for this target, or submit one that stays alive.
jobid="$(squeue -h -u "$USER" -n "$JOB_NAME" -t R -o '%A' | head -n1 || true)"
if [ -z "$jobid" ]; then
jobid="$(sbatch --parsable --job-name "$JOB_NAME" $ALLOC --wrap 'sleep infinity')"
fi
# 2. Wait until the job is running and report its node.
node=""
for _ in $(seq 1 120); do
node="$(squeue -h -j "$jobid" -t R -o '%N' | head -n1 || true)"
[ -n "$node" ] && break
sleep 5
done
[ -n "$node" ] || { echo "job $jobid never started" >&2; exit 1; }
# 3. Create the login-node listener if it is not already up. The login -> compute
# hop uses the login node's own keys/ssh-agent.
remote_cmd="$(cat <<EOF
if ! ss -ltn 2>/dev/null | grep -q '127.0.0.1:${LOGIN_FORWARD_PORT}'; then
nohup ssh -N -o ExitOnForwardFailure=yes -o BatchMode=yes \
-L 127.0.0.1:${LOGIN_FORWARD_PORT}:${node}:${CONTAINER_PORT} ${node} \
>/dev/null 2>&1 &
fi
EOF
)"
ssh "$LOGIN" "$remote_cmd"
# 4. Print the endpoint; the gateway reaches it through ssh_targets=["rosi5"].
printf 'ENDPOINT 127.0.0.1:%s\n' "$LOGIN_FORWARD_PORT"The compute node and the port may change per job; the only contract is the
printed endpoint. refresh rosi5 re-runs the script and follows the new node.
Alternative: if your site lets the gateway reach the compute node directly
(both hops accept the gateway's key), skip the login-node listener entirely —
print ENDPOINT <compute-node>:<port> and add proxy_jump = "rosi5", so the
gateway runs ssh -J rosi5 <ssh_target> and forwards straight to the node.
Troubleshooting: run the script by hand first — the gateway logs its stdout and
includes its stderr in the target's last_error, and the discovered address is
shown as provisioned_endpoint in status/GET /v1/targets/{name}.
Recovering a stopped container (connect_command)
The tunnel can be healthy while the development container itself is stopped.
provision_command does not help there — it discovers an endpoint, it does not
start the container. Use connect_command for that: a trusted argv that the
gateway runs on the remote host (the machine hosting the container) through
the target's SSH route when the container cannot be reached.
[targets.fwk388]
ssh_targets = ["fwk388", "ex_fwk388"]
user = "agent"
client_key = "/home/USER/.ssh/computemcp_container"
host_key_sha256 = "SHA256:..."
connect_command = ["/home/USER/.config/computeMCP-gateway/ensure-container.sh"]
connect_command_timeout = 120.0
connect_command_mode = "on_failure" # default; "always" runs pre-connectBehaviour:
connect_command_mode = "on_failure"(default): run the command only after the container connection fails, then retry the connection once. A healthy container never pays for it.connect_command_mode = "always": run the command before every connection attempt. This is simpler to reason about but the script must be an idempotent no-op when the container already runs; otherwise every call pays the command's cost.Exit status is advisory: the gateway always re-probes/retries and only reports success if the container is really reachable. stdout/stderr are logged.
The command is executed with
ssh -T <route> <argv...>(plusproxy_jumpif set), so it runs on the remote host, not on the gateway.
A reference script ships as
scripts/ensure-container.sh. It inspects the
computeMCP-container (override with COMPUTEMCP_CONTAINER_NAME), starts it if
it is not running, waits for running, and is a no-op when it already runs
(so it is safe with connect_command_mode = "always"). Docker and Podman are
both supported. Copy it to the remote host and reference its absolute path:
scp scripts/ensure-container.sh <remote-host>:~/.config/computeMCP-gateway/
ssh <remote-host> chmod +x ~/.config/computeMCP-gateway/ensure-container.shRun it by hand first to verify it detects and starts the container before wiring it into the gateway.
Troubleshooting: run the script by hand first — the gateway logs its stdout and
includes its stderr in the target's last_error, and the discovered address is
shown as provisioned_endpoint in status/GET /v1/targets/{name}.
File transfer: small files use
computeMCP_file_read/computeMCP_file_write(content in the response). For large or binary files usecomputeMCP_file_upload/computeMCP_file_download, which stream over SFTP and never place file bytes in the tool output.computeMCP_file_upload_treemirrors a whole directory incrementally (skip_existing,include/excludeglobs), andcomputeMCP_file_download(..., recursive=True)mirrors a remote tree. Permissions can be set withcomputeMCP_file_chmod.Command execution supports
env(exported in the remote shell, so it works even when the container sshd does not accept env) andstdin. Persistent reads acceptwait=<seconds>to block for new output instead of polling.
HTTP API (all requests require Authorization: Bearer <token>)
GET /v1/health
GET /v1/targets
GET /v1/targets/{target}
POST /v1/targets/{target}/connect | /refresh | /stop
POST /v1/exec
POST /v1/sessions ; GET /v1/sessions ; GET|DELETE /v1/sessions/{id}
POST /v1/sessions/{id}/write | /read | /resize
POST /v1/reload # admin: re-read config + tokens
GET /v1/clients ; GET /v1/clients/{name} # admin: token/ACL/sessions
GET /v1/clients/{name}/sessions # admin
DELETE /v1/clients/{name}/sessions # admin: close its sessions
GET /v1/files/stat|list|read ; PUT /v1/files/write|upload
POST /v1/files/mkdir|remove|rename|chmod
POST /v1/enroll # UNAUTHENTICATED: queue a request
GET /v1/enroll/{request} # poll (X-Enroll-Secret header)
GET /v1/enroll-requests # admin: list pending
POST /v1/enroll-requests/{request}/approve # admin
POST /v1/enroll-requests/{request}/deny # adminGET /v1/files/read?encoding=stream streams raw bytes; PUT /v1/files/upload
streams the request body into SFTP without buffering. Both support large files.
GET /v1/targets returns only targets allowed by the authenticated client's
ACL. An unauthorized target produces 403 without revealing whether it exists.
GET /v1/sessions lists only the caller's sessions; admins may add ?all=true
or ?client=<name>. Admin-only endpoints never return token values, only a
short sha256: fingerprint.
Security invariants
Agents never receive host SSH keys,
~/.ssh, Docker/Podman sockets, or local command execution. The only credential in a Terok container is its bearer token.Tokens are minted by
--generate-tokens; the gateway stores onlysha256:hashes and the plaintext is never written into a tracked file.The gateway never accepts an SSH destination from a client.
Sessions are owned by the client that created them; another client gets
404.Tokens are compared in constant time and stored as
sha256:hashes.The service runs as a normal user, never root.
Enrollment grants nothing on its own:
/v1/enrollis unauthenticated but only queues a bounded, expiring request; access exists only after an explicit operator approval. The delivered token is readable exactly once, gated by a poll secret, and a request cannot name an unknown target.
Tests
pip install -e '.[test]'
pytest -qThe suite covers config validation/reload (including the default path
resolution and atomic client/token append), auth/ACL, tunnel allocation and
route failover (against a fake ssh), connect_mode validation, the HTTP API
(auth, discovery, exec, dedicated-connection open/close, session isolation,
streaming upload, reload, malformed-config safety), and the enrollment flow
(unauthenticated request, admin approval, one-shot token delivery, .bashrc
update, and end-to-end computeMCP-handshake against a live gateway).
End-to-end against the dev-hal development container used
transport = "direct" for exec/PTY/SFTP/MCP plus a real ssh -N -L tunnel
target with ssh_targets = ["broken-alias", "hal-test"], verifying route
failover, refresh, loopback binding, stop, and automatic recovery after the
tunnel process was killed.
This server cannot be deployed
Maintenance
Related MCP Connectors
Scoped, audited SSH exec, sessions, and SFTP on your saved servers without exposing credentials
Develop, manage, and debug Railway projects, services, and deployments from within agents.
Securely control computers you explicitly pair through files, terminals, processes, screenshots, desktop UI/input, clipboard, browser automation, diagnostics, and document tools.
Remote shell and detached long-running jobs on your own machines — no SSH, open ports or VPN.
Related MCP Servers
- AlicenseNot gradedqualityAmaintenanceEnables AI agents to securely execute commands on remote hosts via SSH and SFTP, with persistent shells, file transfers, screenshots, and an audit log.4MIT
- FlicenseNot gradedqualityCmaintenanceProvides an AI agent with authenticated shell and file access to a sandbox host, plus a restricted gateway for safe deploys on a production host via forced SSH commands.-
- AlicenseNot gradedqualityAmaintenanceEnables AI agents to execute SSH commands and transfer files via SFTP through a centralized HTTP gateway with per-client authorization, layered command policies, audit logging, rate limiting, circuit breakers, and connection pooling.8MIT
- AlicenseNot gradedqualityBmaintenanceEnables local, permissioned development workflows from compatible AI clients, including filesystem editing, interactive terminals, durable jobs, Git operations, and semantic browser testing.111 npm1Apache 2.0