local-ssh and local-mcp
Allows calling allowlisted local Modal MCP server tools through the aggregated MCP connector, enabling Claude Code cloud sessions to use Modal resources.
Allows calling allowlisted local Scopus MCP server tools through the aggregated MCP connector, enabling Claude Code cloud sessions to use Scopus resources.
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., "@local-ssh and local-mcpuse ssh_run to list my project files and show git status"
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.
remote-claude-code-ssh-bridge
A small, security-focused bridge that lets a Claude Code cloud session (the sandboxed VM you get at claude.ai/code) use your own computer: run commands in a locked-down shell, and call the MCP servers you already run locally (browser, GPU sandboxes, notebooks, search, files).
It does this without opening a router port, without giving the cloud VM a private key, and without the cloud agent ever building a tunnel itself. Everything the cloud agent sees arrives as two ordinary MCP connectors:
Connector (suggested name) | URL | What the cloud agent gets |
|
| One tool, |
|
| Every MCP server you allow-listed, merged into one tool list ( |
The rest of this document explains why it is built this way, how every piece works, and how to clone, install and use it. If you only want to get going, jump to Quick start.
Contents
Related MCP server: claude-bridge
Why this exists
A cloud session runs on a remote VM. Your useful tools (a logged-in browser, a GPU, a Colab tab, local notebooks, project files) live on your own machine. Three obstacles stand in the way:
Reachability. Your computer is behind NAT. You do not want to forward a port.
Trust. Handing a cloud VM an SSH key, a shell on your real account, or your service logins is a bad idea. Whatever it gets must be narrow and short-lived.
The cloud session's own permission check. In auto mode a second model reviews each tool call and blocks things that look like opening a tunnel, decoding a private key, or reaching a host by a non-direct route. A hand-built SSH tunnel from the VM trips exactly those rules.
The design answer is to keep every sensitive step on your machine and give the cloud agent only native MCP connector tools:
A Cloudflare quick tunnel publishes a single loopback web service (the gateway). No inbound port, no account, no domain.
The gateway authenticates every request (OAuth 2.1 for the connectors, a signed token for the older WebSocket path).
For the shell, the gateway makes the SSH connection itself, over loopback, to a dedicated, unprivileged account whose login is forced into a chroot jail. The cloud agent never sees the key, the token or a tunnel.
For MCP servers, the gateway starts your already-installed stdio servers from a root-owned allowlist and re-exposes them over Streamable HTTP.
Credentials of services you are logged in to (GitHub, NotebookLM, Hugging Face, Google, Modal) never enter the jail or the cloud VM; where a cloud agent needs them, a small broker on your machine performs the single approved action.
Architecture at a glance
Claude Code cloud session (Anthropic VM)
│ native tools: mcp__local-ssh__ssh_run mcp__local-mcp__<server>__<tool>
▼
Anthropic connector proxy ──── HTTPS ────► https://<random>.trycloudflare.com
│ (Cloudflare quick tunnel)
▼
cloudflared (host) ──► 127.0.0.1:8798
│
┌───────────────────────────────────────┴──────────────────────────────┐
│ gateway/ (Starlette app, one process) │
│ │
│ /.well-known/* /register /authorize /token OAuth 2.1 server │
│ /shell/mcp/ ──► shell_mcp.py (ssh_run) │
│ /all/mcp/ ──► mcp_proxy.py (aggregate of every allowlisted MCP)
│ /<alias>/mcp/ ──► mcp_proxy.py (one MCP per route) │
│ /ssh ──► WebSocket ⇄ TCP relay to sshd (legacy path) │
│ /healthz /ssh-client.py /oauth/upstream-callback │
└───────┬──────────────────────────────┬───────────────────────────────┘
│ ssh over loopback │ stdio children / HTTP upstreams
▼ ▼
sshd on 127.0.0.1:2223 (dedicated) your MCP servers (allowlist)
│ ForceCommand e.g. browser, Colab, Modal, Scopus …
▼
claude-shell-launch ─► sudo claude-shell-runner ─► chroot /srv/claude-shell-root
│ setuid claude-shell, bash -lc "<cmd>"
└─ "push …" ─► sudo git-push-broker (one-shot, locally approved)Two things never leave your machine: the SSH private key for the current session
(~/.remote-gateway/ssh-session/current.env, mode 0600) and the gateway signing secret
(~/.remote-gateway/secrets.env, mode 0600).
How it works, piece by piece
1. The tunnel and the process supervisor
https_bridge/start.sh --foreground is the ExecStart of the systemd unit
remote-claude-bridge.service. It:
loads
REMOTE_GATEWAY_TOKEN(the signing secret) from~/.remote-gateway/secrets.env;starts the gateway:
python3 gateway/server.pybound to127.0.0.1:8798;starts
cloudflared tunnel --url http://127.0.0.1:8798and scrapes the randomhttps://….trycloudflare.comhostname from its log into~/.remote-gateway/https-bridge/url.txt;writes its own PID to
reload-capable.pidand traps SIGHUP: on HUP it restarts only the gateway child, keepingcloudflared(and therefore the public URL) alive. This is how new gateway code goes live with no URL change.
start.sh (top level) is the user-facing wrapper: it installs the unit if missing,
reuses a healthy tunnel (local and public /healthz both answer), or restarts the unit
and waits up to 120 s for a verified public endpoint.
A quick tunnel gets a new random hostname every time the tunnel (not just the gateway) restarts. Whenever that happens you edit the two connector URLs (see Troubleshooting).
2. The gateway (gateway/)
One Starlette application, built by gateway/server.py:build_gateway(). See
gateway/README.md for a module-by-module walkthrough. Routes:
Route | Auth | Purpose |
| none | Liveness: status, transport name, allowlisted aliases, aggregate and shell paths |
| none | OAuth discovery for connector clients |
| none (inert) | RFC 7591 dynamic client registration; grants nothing by itself |
| consent form needs the current session token | Issues an authorization code (PKCE S256 required) |
| PKCE / refresh token | Issues route-bound access and refresh tokens |
| bearer | One allowlisted MCP server, Streamable HTTP |
| bearer bound to | All allowlisted MCP servers merged into one list |
| bearer bound to | The |
| signed cookie | Relays raw bytes to the local sshd (legacy fallback) |
| none | Serves |
| single-use | Finishes a sign-in the gateway started to an upstream MCP |
Every MCP mount is wrapped in AuthenticatedMCP, which accepts exactly two credentials:
a directly presented signed session token, or an OAuth access token whose recorded
resource equals that route's alias. A token for playwright cannot open /all, a token
for all cannot open /shell, and so on. Missing or invalid credentials get
401 with a WWW-Authenticate header that points clients at the discovery document.
3. Authentication in detail
Signing secret.
REMOTE_GATEWAY_TOKENis never accepted from anyone; it only signs and verifies short-lived tokens (HMAC-SHA256).Session token ("bundle token").
gateway/session.pyissuesbase64url({"aud":"ssh","exp":…,"v":1}).base64url(HMAC). Maximum lifetime: two hours (DEFAULT_TTL_SECONDS).remote-gateway-testmints one together with a fresh ed25519 SSH key and stores both incurrent.env.OAuth 2.1 (
gateway/oauth.py). Implements discovery, dynamic client registration, authorization code with mandatory PKCE S256, exactredirect_urimatch, and refresh. Redirect URIs are restricted tohttps://claude.ai,https://claude.comand loopbackhttp. The consent page issues a code only if the human pastes a currently valid session token. Failed consents are rate-limited (5 per IP and 30 total per 10 minutes). Access tokens last 2 hours; refresh grants last 30 days (24 hours for the shell route). Only SHA-256 hashes of tokens are written to~/.remote-gateway/oauth-state.json.Why the long refresh grant is safe enough. A connector is account-level, so a long grant means you do not re-authorize for every new cloud session. Reading state or running commands is still gated: the shell tool also needs a valid two-hour bundle on your disk, so a stale grant cannot run anything.
4. MCP re-exposure (gateway/mcp_proxy.py)
/etc/claude-remote/mcp-allowlist.json (root-owned) lists the servers. Each entry is
either a stdio server (command absolute path, args, optional cwd that must stay
under the projects folder) or an HTTP upstream ({"url": …}, https or loopback http).
Callers can never choose a command, path, environment or new alias.
LocalStdioUpstream owns one long-lived child per alias, started lazily on first use and
restarted on next use if it died. The child gets a minimal environment (HOME, USER,
LOGNAME, a fixed PATH, LANG) and no gateway secrets. For HTTP upstreams the gateway
acts as an OAuth client (gateway/upstream_auth.py), storing tokens in
~/.remote-gateway/upstream-oauth/ with mode 0600.
/all/mcp/ is built by build_aggregate():
tool names become
<alias>__<tool>; names that would exceed the client's 64-character tool-name limit are shortened with a stable 8-hex hash (public_tool_name);every alias's last good tool list is cached under
~/.remote-gateway/tool-cache/, so an MCP that is down or signed-out stays listed and its calls return an explanation;one extra tool,
gateway__sign_in(alias), starts a sign-in to an HTTP upstream and returns a URL for the human to finish (PKCE verifier and tokens stay on your machine).
5. The shell connector (gateway/shell_mcp.py)
ssh_run(command, timeout_seconds) is a thin, careful wrapper around the local ssh
client:
read
current.env; verify the token's signature and expiry (needs ≥ 10 s left);write the key to a private temp file (mode 0600, removed in
finally);run
ssh -T -i <key> -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -p 2223 claude-shell@127.0.0.1 <command>with the command as one argv element (never interpolated into a local shell);keep at most 64 KB of stdout and 64 KB of stderr (it keeps draining so the child never blocks), kill the process group on timeout, allow 4 concurrent calls;
return
exit_code, stdout and stderr. SSH's own failure (exit 255) is reported as a tool error, distinct from the remote command's exit status.
6. The jail (ssh/)
A dedicated sshd (/etc/claude-remote/sshd_config) listens only on 127.0.0.1:2223,
public-key only, no forwarding of any kind. Four accounts, each with a forced command:
Account | Forced command | Can do |
|
| Run bash inside the chroot jail |
|
| SFTP: projects read/write, skills and agents read-only |
|
| Start exactly |
|
| Start only the reviewed browser MCP |
For claude-shell, claude_shell_launcher.py treats the original command as opaque. If it
starts with push it goes to the Git push broker; everything else goes to
sudo claude-shell-runner <command>. The runner (root, via a single sudoers rule) does
chroot /srv/claude-shell-root, chdir /workspace/projects, drops all groups, setgid,
setuid to claude-shell, then exec bash -lc <command> with a fixed environment
(HOME=/workspace/projects, a fixed PATH, PYTHONUSERBASE, CMAKE_PREFIX_PATH, …).
The chroot is assembled at boot by claude_shell_mounts.sh (unit
claude-shell-sandbox.service) from bind mounts:
Path in the jail | Source on the host | Mode |
| same paths | read-only |
|
| read-write |
|
| read-only |
|
| read-only |
| CUDA toolkit, Nsight Compute | read-only |
|
| read-only |
| vcpkg install tree, CMake's Python package | read-only |
| host devices ( | read-write |
| private 4 GB tmpfs | read-write |
| host files | read-only |
| NotebookLM broker socket | read-only |
Everything else (the rest of your home folder, SSH keys, credentials, shell history,
~/.claude) is simply absent.
7. Brokers: doing one thing for the jail without giving it the secret
NotebookLM (
claude_nlm_broker.py/claude_nlm_client.py).nlm-v2inside the jail is a tiny client that sends the arguments over a Unix socket to a systemd-managed broker running as you. The broker validates the command against an allow list (nologin,config,share,export,delete,install, …), maps/workspace/projectspaths to host paths and rejects any other absolute path, caps request and output sizes, and runs the real CLI. Your NotebookLM login is never visible to the jail.Git push (
claude_git_push_broker.py). The jail has no GitHub credential. To push, you runremote-gateway-git-authorize REPO COMMIT_SHA BRANCHon your machine; the broker verifies the repository (exactly oneoriginunder your projects folder matchinghttps://github.com/<owner>/<repo>.git) and commit, then prints a single-use, 10-minute approval nonce. The agent runspush REPO COMMIT_SHA BRANCH NONCEthrough the shell; the broker re-checks everything, consumes the nonce before touching the network, mints credentials (GitHub App installation token, or, if no App is configured, yourghlogin) inside the root process and pushes with hooks and config disabled. The token never reaches the jail.
8. The legacy SSH-over-WebSocket path
Before the shell connector existed, a client could reach the same sshd through
WS /ssh (cookie remote_gateway_ssh = the session token) using
https_bridge/ssh_ws_proxy.py as an OpenSSH ProxyCommand. It is kept as a fallback and
for SFTP. It requires the cloud side to hold the session token and key, which is exactly
what a cloud session's permission check tends to block, so prefer the connectors.
Repository layout
remote-claude-code-ssh-bridge/
├── README.md this file
├── requirements.txt Python packages the gateway and tests need (pinned)
├── pyproject.toml package metadata (web-server basics only)
├── start.sh user command: start/verify the bridge (remote-gateway-start)
├── stop.sh user command: stop everything (remote-gateway-stop)
├── start-test.sh user command: validate + show/rotate the session bundle (remote-gateway-test)
├── sync.sh user command: hot-deploy changes (remote-gateway-sync)
├── login.sh user command: sign the gateway in to an HTTP MCP (remote-gateway-mcp-login)
├── gateway/ the public web application (see gateway/README.md)
│ ├── server.py routes, auth wrapper, /ssh WebSocket relay, health
│ ├── oauth.py OAuth 2.1 authorization server (discovery, DCR, PKCE, refresh)
│ ├── session.py signed two-hour session tokens
│ ├── mcp_proxy.py allowlist loader, stdio/HTTP upstreams, per-alias and aggregate servers
│ ├── shell_mcp.py the ssh_run tool
│ └── upstream_auth.py OAuth *client* for HTTP upstreams + gateway__sign_in flows
├── https_bridge/ process supervision and the legacy client
│ ├── start.sh / stop.sh systemd ExecStart/ExecStop: gateway + cloudflared
│ ├── install-service.sh installs remote-claude-bridge.service
│ ├── systemd/ the unit file
│ ├── ssh_ws_proxy.py OpenSSH ProxyCommand for the legacy WebSocket path
│ ├── README.md bridge-level notes
│ └── tests/ gateway, OAuth, MCP proxy and shell-tool tests
├── ssh/ the restricted SSH service and its helpers
│ ├── install.sh one-time provisioning (accounts, ACLs, keys, units, sudoers)
│ ├── sshd_config dedicated sshd (port 2223, loopback only)
│ ├── claude_shell_launcher.py / claude_shell_runner.py / claude_shell_mounts.sh the jail
│ ├── claude_mcp_launcher.py / claude_mcp_runner.py / claude_playwright_*.py MCP over SSH
│ ├── claude_nlm_broker.py / claude_nlm_client.py NotebookLM broker
│ ├── claude_git_push_broker.py / claude-git-push-askpass / remote-gateway-git-authorize push approval
│ ├── *.sudoers, *.gitconfig, *.tmpfiles.conf, git-push-policy.example.json root-owned policy files
│ ├── mcp-allowlist.json example allowlist (the author's servers: edit before installing)
│ ├── systemd/ sshd, sandbox, broker and mount units
│ ├── README.md notes on the SSH service
│ └── tests/ launcher, runner and broker tests
└── tests/ lifecycle-script testsRequirements
This was built and run on CentOS Stream 9 under WSL2 (systemd enabled) with an NVIDIA
GPU exposed through WSL. Plain Linux with systemd should work as well; the GPU
mounts are guarded and skipped when the device is absent, but only WSL's /dev/dxg GPU
path is wired up.
Need | Why |
Linux with systemd, | installs accounts, units, sudoers, mounts |
| the dedicated daemon and the client the gateway runs |
| provisioning and the lifecycle scripts |
Python 3.12 at | the scripts hard-code this interpreter |
the packages in |
|
| the quick tunnel (no Cloudflare account needed) |
the MCP servers you want to expose, installed locally | whatever you put in the allowlist |
Account and path names. The code assumes the installing user is called centos and
lives in /home/centos. These values are literals in the sources (see
Adapting the paths). Plan one mechanical replacement
before installing.
Quick start
Read Security model first. The install adds four system accounts, sudoers rules, mounts and a systemd service to your machine.
1. Clone
git clone https://github.com/us-llm-engineer/remote-claude-code-ssh-bridge.git
cd remote-claude-code-ssh-bridgeKeep the checkout in a stable place: the systemd unit and the scripts refer to its path.
The author keeps it at ~/.claude/mcp-servers/remote-gateway; the examples below use
$REPO for wherever you cloned.
export REPO="$PWD"2. Adapt the account and paths
The sources use the literals centos (user and group) and /home/centos (home). Replace
them with yours. Check what will change first:
grep -rIn '/home/centos\|centos' --exclude-dir=.git . | grep -v '^./README.md' | wc -lThen apply it (example: user alice, home /home/alice; use your real values). Do the
home path first, then the bare account name:
NEWHOME=/home/alice; NEWUSER=alice
grep -rIl '/home/centos' . --exclude=README.md --exclude-dir=.git | xargs sed -i "s|/home/centos|$NEWHOME|g"
grep -rIl '\bcentos\b' . --exclude=README.md --exclude-dir=.git | xargs sed -i "s/\bcentos\b/$NEWUSER/g"
git diff --stat # review: only paths and the account name should change(The account name appears as a user and group in the systemd unit, sudoers rules, launchers, the broker units and the tests, which is why the second command runs over everything.) After this, the test suite passes except the two host-specific tests described under Tests.
Two more places are host-specific by design:
The allowlist
ssh/mcp-allowlist.jsonlists the author's MCP servers. Replace its entries with your own (see Choosing which MCP servers to expose).Jail mounts in
ssh/claude_shell_mounts.sh. The GPU device, CUDA toolkit (CUDA_ROOT), Nsight Compute, the Python 3.12 packages (PY312_SITE) and the WSL driver folders are skipped when missing, so no GPU is fine. Three host directories are required to exist, otherwise theclaude-shell-sandboxservice fails at boot. If you do not use those toolchains, create them empty (or delete the matching lines):mkdir -p ~/.local/bin # mounted read-only at /opt/tools/bin mkdir -p ~/vcpkg/installed/x64-linux # mounted read-only at /opt/vcpkg mkdir -p ~/.local/lib/python3.9/site-packages/cmake # mounted read-only at /opt/python-site/cmake
3. Install the Python packages
For the interpreter the scripts call (/usr/local/bin/python3, version 3.12):
/usr/local/bin/python3 -m pip install --user -r requirements.txtInstall cloudflared and make sure it is where https_bridge/start.sh expects it:
mkdir -p ~/.local/bin
curl -fsSL -o ~/.local/bin/cloudflared \
https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64
chmod +x ~/.local/bin/cloudflared && ~/.local/bin/cloudflared --version4. Create the signing secret
mkdir -p -m 700 ~/.remote-gateway
umask 077
printf 'REMOTE_GATEWAY_TOKEN=%s\n' "$(openssl rand -hex 32)" > ~/.remote-gateway/secrets.env
chmod 600 ~/.remote-gateway/secrets.envThis value only signs and verifies tokens. You never paste it anywhere.
5. Provision the restricted SSH service (once)
sudo -v # confirm passwordless sudo works for your user
bash ssh/install.shWhat it does (read the script; it is commented): creates the four system accounts, sets
ACLs so the restricted accounts can reach only the projects tree (read-write) and
skills/agents (read-only), installs the launchers into /usr/local/libexec, the sudoers
rules into /etc/sudoers.d, the allowlist and sshd_config into /etc/claude-remote,
generates the host key, creates the systemd units, builds the jail mounts, starts the
NotebookLM broker socket, and starts the dedicated sshd on 127.0.0.1:2223.
6. Start the bridge
bash start.shIt installs remote-claude-bridge.service on first use, starts the gateway and
cloudflared, waits for a verified public URL and prints it, for example:
Bridge: https://some-random-words.trycloudflare.com
Remote MCP base: https://some-random-words.trycloudflare.com/<approved-alias>/mcp/7. Add the shell aliases (optional but convenient)
cat >> ~/.bashrc <<EOF
# ---- remote-gateway BEGIN ----
alias remote-gateway-start='$REPO/start.sh'
alias remote-gateway-stop='$REPO/stop.sh'
alias remote-gateway-test='$REPO/start-test.sh'
alias remote-gateway-sync='$REPO/sync.sh'
alias remote-gateway-mcp-login='$REPO/login.sh'
# ---- remote-gateway END ----
EOF
source ~/.bashrc8. Deploy the helper set once
sync.sh installs the pieces ssh/install.sh does not (the Git push broker and its
sudoers rule, the remote-gateway-git-authorize command) and re-checks everything. It
needs the bridge running, which it now is:
remote-gateway-sync9. Mint the session bundle and check the whole chain
remote-gateway-testIt prints the public endpoint, the remaining lifetime, and the bundle:
Credential status: rotated
Token expires: 2026-10-03 11:02:41 UTC (120 seconds…; about 120 minutes remaining)
Endpoint: https://some-random-words.trycloudflare.com
REMOTE_GATEWAY_SSH_TOKEN=eyJ…
REMOTE_GATEWAY_SSH_KEY_B64=LS0t…Treat both values as secrets. For the connectors you need only the token (once, on a consent page). Continue with the next section.
Configure Claude: allowed domains and the two connectors
Allowed domains for the cloud environment (optional but recommended)
Connector traffic travels through Anthropic's servers and does not use your cloud
environment's network allowlist. You only need to allow the tunnel host if the VM must
reach it directly, for example the legacy WebSocket path or a curl of
/healthz from inside the session. To allow it:
Open claude.ai/code and edit the cloud environment you use (the environment dialog).
Set Network access to Custom.
In Allowed domains add one line:
*.trycloudflare.com(One domain per line, no URLs;
*.matches every subdomain.) You can keep "Also include default list of common package managers" ticked so installs still work.Save. Changes apply to new sessions.
Add the two connectors
Do this once per Claude account, after the bridge is running and you have a bundle from
remote-gateway-test.
Step | Connector 1: shell | Connector 2: tools |
1. claude.ai → Settings → Connectors → Add custom connector | Name | Name |
2. URL (note the trailing slash) |
|
|
3. Add / Connect | the gateway's consent page opens | same |
4. On the consent page paste the | grant lasts 24 h | grant lasts 30 days |
5. In each new cloud session, open the connector menu and enable both | tool: | tools: |
Rules and tips:
Name length. The client builds tool names as
mcp__<connector>__<tool>and caps them at 64 characters. Keep connector names to 13 characters or fewer (local-sshandlocal-mcpare 9).Do not paste
REMOTE_GATEWAY_SSH_KEY_B64anywhere. The connectors never need it; the gateway reads the key from your disk.The token on the consent page is valid for two hours. If it has expired, run
remote-gateway-testagain and paste the new one.When the add dialog says the server was "not found (404)", the gateway on your machine is not running the code that serves that route yet. Run
remote-gateway-sync, then retry.After the tunnel restarts the hostname changes: edit each connector's URL and reconnect.
Using them from a cloud session
ssh_run { "command": "pwd && ls && python3 -c 'import sys; print(sys.version)'", "timeout_seconds": 60 }Returns exit_code, stdout, stderr (each capped at 64 KB). A non-zero exit_code is a
normal result; an error result means the tool could not run it (session expired,
timeout, SSH unreachable). ssh_run only works while a valid bundle exists on your
machine, so run remote-gateway-test at the start of each working period (see below).
For MCP tools the agent calls, for example, mcp__local-mcp__playwright__browser_tabs.
Servers that need their own login (HTTP upstreams in the allowlist) appear in the list
immediately; the first call returns "sign-in required" and the agent calls
gateway__sign_in with the alias. It hands you a URL; you finish the sign-in in a browser
and the agent retries.
Choosing which MCP servers to expose
Edit the allowlist before ssh/install.sh (or reinstall it later). Each entry is
exactly one of these shapes:
{
"servers": {
"my-stdio-server": {
"command": "/usr/bin/python3",
"args": ["/home/alice/tools/my_server.py"],
"cwd": "/home/alice/data/projects/some-project"
},
"my-http-server": { "url": "http://127.0.0.1:8791/mcp" }
}
}commandmust be an absolute path;cwd(optional) must stay under the projects folder.Aliases may not contain
/or__and may not beallorshell.The live copy is root-owned:
/etc/claude-remote/mcp-allowlist.json. After editing the repo copy runsudo install -o root -g root -m 0644 ssh/mcp-allowlist.json /etc/claude-remote/mcp-allowlist.jsonand thenremote-gateway-syncso the gateway reloads it.For HTTP upstreams that need a login, sign in once with
remote-gateway-mcp-login <alias>(browser opens on your machine) or let the agent start it withgateway__sign_in.
Script reference
All scripts are plain Bash; read them, they are short and commented. "Alias" is the name
used in the Quick start's ~/.bashrc block.
Script (alias) | Run as | What it does | Typical use |
| you | Takes a lock, installs the systemd unit if missing, reuses a healthy tunnel or restarts the unit and waits (≤ 120 s) for a verified public | after boot, or when the tunnel looks stale |
| you |
| when you are done, or as an emergency off-switch |
| you | Runs | at the start of each working period, and when a connector asks for a token |
| you | Hot-deploys reviewed changes with no URL change: runs the test suites; boots the new gateway on a private port (8799) and requires | after every code change; first-time install of the push broker |
| you | Runs | HTTP MCP servers that need an account |
| you, once | Provisions everything for the SSH service (accounts, ACLs, keys, units, sudoers, mounts, broker socket, sshd) | first install, or after changing account or path literals |
| you |
| when an agent asks to push |
| root (boot, via | Builds the jail's bind mounts | automatic |
| systemd | Supervisor: gateway + | automatic |
| systemd | Kills the PIDs in | automatic |
| you (via | Installs and enables the unit | automatic |
Worked examples:
# Start of the day
remote-gateway-start # tunnel up (prints the URL)
remote-gateway-test # prints a bundle; paste the TOKEN into a connector if it asks
# You changed gateway code
python3 -m pytest -q # all green?
remote-gateway-sync # candidate boots on :8799, live gateway reloads, URL unchanged
# An agent wants to push commit 0123…cdef of repo "my-repo" to branch "main"
remote-gateway-git-authorize my-repo 0123456789abcdef0123456789abcdef01234567 main
# → prints an approval nonce; give it to the active session only.
# The agent then runs, through ssh_run: push my-repo <sha> main <nonce>
# Shut everything down
remote-gateway-stopDay-to-day operation
When | What to do |
Start a work period |
|
| run |
A connector shows an auth error | reconnect it; paste the current token |
Tunnel URL changed | edit both connector URLs; reconnect |
You changed the code |
|
You changed the allowlist | install the file as shown above, then |
Revoke all connector grants | delete |
Look at logs |
|
Emergency stop |
|
Configuration reference
Environment variables (gateway process):
Variable | Default | Meaning |
| required | signing secret (from |
|
| listen address |
|
| refresh-grant lifetime for |
|
| refresh-grant lifetime for |
Ports: 8798 gateway (loopback), 8799 candidate during sync.sh, 2223 dedicated sshd
(loopback), 8797 temporary sign-in callback of login.sh.
Lifetimes: session token and SSH key 2 h · OAuth access token 2 h · OAuth refresh grant
30 d (/shell: 24 h) · authorization code 5 min · Git push approval 10 min · tool output cap
64 KB · tool timeout ≤ 600 s · 4 concurrent ssh_run calls.
Files and directories:
Path | Contents |
|
|
| tunnel URL, PIDs, logs |
| current bundle: token, key (base64), expiry (0600) |
| loopback host key; transient key files |
| registered clients, hashed tokens |
| tokens for HTTP upstreams (0600) |
| last good tool list per alias |
|
|
| root-owned launchers, runner, brokers |
| the narrow sudo rules |
| the jail root; |
Optional Git-push policy (/etc/claude-remote/git-push-policy.json, root-owned 0600):
copy ssh/git-push-policy.example.json. Fill in a GitHub App (app_id, installation_id,
private_key path) to push with an App installation token; leave the fields empty to use
your own gh login inside the root broker. Either way the token never enters the jail.
Security model
What an attacker with the connector grant can do: run commands as the unprivileged
claude-shell user inside the jail, for as long as a valid two-hour bundle exists on your
disk. That means: read and write everything under your projects folder, read skills and
agents, use the GPU, reach the internet (outbound only), and call NotebookLM through the
broker. They cannot see your home folder, keys or other logins, become root, listen on a
port, or push to GitHub without a fresh approval you issued.
Trust boundaries and what protects each:
Boundary | Protection |
Internet → gateway | OAuth 2.1 + PKCE, exact redirect URIs, route-bound tokens, hashed storage, consent rate limits; unsigned or expired tokens rejected |
Gateway → sshd | loopback only; |
sshd → jail | forced command; opaque command string; root-owned runner via one sudoers rule; chroot, dropped groups, unprivileged UID |
Jail → host | only the bind mounts listed above; read-only wherever possible; no host devices except the GPU |
Jail → secrets | brokers (NotebookLM, Git push) run outside the jail and never return credentials |
Gateway → MCP servers | root-owned allowlist; fixed command, arguments and working directory; minimal environment |
Caveats you should know:
The projects folder is read-write for the jail. Anything in it (all projects, not just one) can be changed or deleted by a command the agent runs. Keep backups or use version control.
A tunnel hostname is an unguessable but public URL; the security comes from the authentication above, not from secrecy of the URL.
The quick tunnel offers no uptime guarantee and a new hostname per start.
claude-sync's ACLs and theclaude-shellwrite access are applied recursively to your projects tree byssh/install.sh; read that section before you run it.The example allowlist and several literals are specific to the author's machine (Adapting the account and paths).
If you find a vulnerability, please report it privately (the repository's Security tab, "Report a vulnerability") instead of opening a public issue.
Tests
/usr/local/bin/python3 -m pip install --user -r requirements.txt
python3 -m pytest -q72 tests, no network or real tunnel needed, run in a few seconds:
File | Tests | Covers |
| 13 | route auth, OAuth consent and PKCE, stdio upstream lifecycle, aggregate namespacing, tool cache, sign-in tool and callback (including a real OAuth round trip against a second gateway), allowlist validation |
| 13 |
|
| 4 | two-hour session cookie: valid, tampered, wrong secret, over-long; which routes the gateway mounts |
| 15 | forced-command parsing, runner and allowlist validation |
| 19 | NotebookLM broker allow/deny rules, path mapping, size limits |
| 8 | text-level checks that |
Two tests are tied to the author's machine and fail on a fresh install until you
adapt them: ssh/tests/test_launcher_runner.py::test_all_registered_programs_exist_on_this_host
checks that every program in ssh/mcp-allowlist.json exists on disk, and
https_bridge/tests/test_ssh_transport.py::test_gateway_exposes_only_reviewed_mcp_aliases_plus_auth_and_ssh
builds the gateway from the installed /etc/claude-remote/mcp-allowlist.json and expects
the author's alias set. After you replace the allowlist with your own servers, update those
two expectations.
Troubleshooting
Symptom | Cause and fix |
Adding a connector says 404 / Not found | The running gateway does not serve that route yet (old code). |
401 on a route | Normal until you authorize. Connect the connector and paste a current token |
Consent page says "That token is not correct." | Token expired or from an older bundle. |
Too many failed attempts on consent | Rate limit (5 per IP per 10 min). Wait, then retry with the right token |
|
|
| sshd down or bundle not authorized: |
Tools show up but a call says sign-in required | HTTP upstream: let the agent call |
A tool name looks truncated with a hash | Intended: names over the limit are shortened deterministically |
Connector works, then fails after a restart | Tunnel hostname changed: edit the URL |
| Run |
| Old copy of the sync script; the current one only prepares unmounted targets |
| Your user needs passwordless sudo for the install |
Browser MCP cannot connect | Its own extension/config must be running on your machine; the bridge only starts the process |
Limitations and honest caveats
Written for one machine first. Account name, home folder, interpreter path and a handful of tool locations are literals; the README shows how to replace them, but this is not a packaged installer.
pyproject.tomllists only the web-server basics (the MCP SDK is also required);requirements.txtis the source of truth for dependencies.The cloud client's behaviour (connector UI, permission checks, name limits) belongs to that product and can change; the notes here describe what was observed when this was built.
The jail is a chroot with a dropped-privilege user, not a VM or container. It is a reasonable boundary for a tool you control and trust to be steered, not for hostile code.
Quick tunnels are for development. For a stable URL use a named Cloudflare tunnel on a domain you own (not covered here).
License
MIT. See LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Use your Mac, Windows or Linux computer from ChatGPT, Claude or Codex: files, commands, documents.
- QuallaaOAuthcom.quallaa
Talk to your public-facing AI from any MCP client — Claude, ChatGPT, Cursor, Cline, Windsurf.
Hosted MCP server connecting claude.ai, ChatGPT and other AI apps to your own computer
Secure tunneling, reverse proxy and remote access for local applications.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceExposes local OpenCode instances as remote MCP servers for Claude and ChatGPT, enabling terminal access, session management, and interactive human-in-the-loop workflows. It simplifies deployment for local machines using Cloudflare Tunnels to provide secure public connectivity and OAuth support.-
- AlicenseNot gradedqualityDmaintenanceEnables remote-controlled shell/GUI access to your machine from Claude via MCP, with phone approval and audit logging.1MIT
- AlicenseNot gradedqualityCmaintenanceEnables remote MCP clients like ChatGPT to run shell commands and manage files on your local machine via a Cloudflare tunnel, exposing tools for file operations, search, and task management.4MIT
- AlicenseNot gradedqualityAmaintenanceEnables MCP clients to connect to a local workspace over a public tunnel and lets them run shell commands and transfer files bidirectionally.262 npm21GPL 3.0