Skip to content

mcpx: remote access over ssh -- TUI, RPC, reverse tunnels, and why not a built-in ssh server #1880

Closed

Description

@dezren39

Relocated from dezren39/darwin-nix#85 on 2026-10-28 (placement audit): mcpx's single source lives in developing-today/code (pkgs/mcpx); re-home tracked at #309. Original body below; issue references inside it are historical (dezren39/nix numbering).

Parent: #174

Summary

Reach a daemon, and the TUI, from another machine. Three separate wants get conflated under "ssh": a
terminal UI over ssh, RPC over ssh (the /v1 API and MCP tunnelled through an ssh connection), and
inbound access to a daemon behind NAT (a reverse tunnel, or a built-in reverse shell). They have very
different costs, and two of the three are already mostly solved by transports mcpx has.

Original notes

  • ssh rpc, ssh server, ssh tui
  • reverse-shell/shell/ssh/tui

Current state (what mcpx does today, with file refs)

capability today file
full-screen TUI, six views mcpx tui (browse/log/stats/servers/sessions/storage) internal/tui/tui.go:41-51
the TUI reads through an interface, not the client tui.Source — eight methods, a fake drives it in tests internal/tui/data.go:19-28
talk to a daemon elsewhere daemon.endpoint, scope client, accepts a URL or unix:///path internal/settings/registry.go:350-363, internal/cli/client.go:130-160
listen wider than loopback daemon.address (default 127.0.0.1), daemon.port internal/settings/registry.go:343-375
MCP over a spawned process mcpx serve --transport stdio; every other transport is explicitly refused and redirected to the daemon's /mcp internal/cli/serve.go:358-383
MCP over HTTP the daemon serves it at /mcp internal/daemon/routes_proto.go
events GET /v1/events (SSE) internal/api/ops.go
ssh is already an acknowledged transport "A unix socket can be ssh-forwarded" docs/exec.md:189

Two facts that shape every option below:

  1. ssh host mcpx <anything> already works. ssh host mcpx tui is an SSH TUI. ssh host mcpx serve --transport stdio is MCP-over-SSH and is a valid command for any MCP client's stdio transport, today,
    with no mcpx change. What does not work is talking to a remote daemon from a local mcpx when the
    remote is only reachable over ssh: daemon.endpoint understands http(s):// and unix:// and nothing
    else.
  2. mcpx has five direct Go dependencies and the package file treats that as a design property
    (pkgs/mcpx/package.nix:36-53 enumerates and justifies each one). charmbracelet/wish v1.4.7 adds
    ~15 modules to the graph including go-git/go-git/v5, ProtonMail/go-crypto and cloudflare/circl
    (verified from https://proxy.golang.org/.../wish/@v/v1.4.7.mod). Unused packages are dropped by the
    linker, but go.sum and the nix vendorHash are not, and mcpx currently has no golang.org/x/crypto
    at all. wish v1.4.7 requires bubbletea v1.3.4; mcpx is on v1.3.10, so the bubbletea major line does match.

Approaches

Approach A — a built-in SSH server (mcpx ssh / daemon.ssh.*), least recommended

Import charmbracelet/wish and serve the existing bubbletea program over SSH with
wish/bubbletea.Middleware. Because tui.Source is already an interface and the model already takes its
size from the terminal, the TUI part genuinely is nearly free — an afternoon.

Everything around it is not. An SSH server needs a host key (generate where? rotate how? what does a client
do when it changes?), an authorised-keys policy, a per-connection identity that the /v1 API has no concept
of, PTY allocation, window-change handling, and a listener whose lifetime is tied to a daemon that may
idle-exit under the connection. It is a second implementation of something every target machine already
runs, with worse defaults, and it makes mcpx a network daemon that accepts credentials — a category it is
not in today. daemon.address is currently documented as "the network it is reachable from is the access
control"; a built-in sshd is a promise that mcpx cannot keep at that level of care. The dependency cost
above is real but is the smaller objection.

If it is built anyway, it should be a separate binary or a build tag, not linked into the default mcpx,
and it should refuse to start without an explicit authorizedKeys path rather than defaulting to anything.

Approach B — teach daemon.endpoint an ssh:// scheme, tunnelled through the system ssh

No server, no keys, no crypto dependency: mcpx becomes an SSH client, and only by exec'ing the ssh the
user already has configured. daemon.endpoint = ssh://user@host makes NewClientAt install an
http.Transport.DialContext that spawns ssh -q user@host mcpx <stdio-proxy-subcommand> and returns the
process's stdin/stdout pipes as a net.Conn. The subcommand is a ~40-line pipe between stdio and the local
daemon socket. Everything downstream — /v1, SSE, /mcp, the generated client — is unchanged, because it
is all HTTP over a byte stream and the byte stream is the only thing that moved.

What this buys, in one setting:

  • mcpx tui --daemon-endpoint ssh://build-box is a remote TUI, rendered locally, with local colours and
    local terminal handling. Better than a wish-served TUI, not worse: no PTY negotiation, no resize
    forwarding, works over a flaky link because only the HTTP stream is on the wire.
  • mcpx call, mcpx exec, mcpx status, mcpx ls all point at the remote daemon for free — they already
    honour daemon.endpoint (internal/settings/registry.go:361-362 lists the commands).
  • Auth, host-key verification, ~/.ssh/config, jump hosts, agent forwarding, ProxyCommand, Match blocks:
    all inherited, none implemented.

Costs and open edges. The remote needs mcpx on PATH (ssh://host?bin=/opt/mcpx/bin/mcpx covers the
rest). One ssh process per connection unless ControlMaster is set up — worth documenting, possibly worth
passing -o ControlMaster=auto -o ControlPersist=60s by default. Lifetime follows #87's rule (#181
collision 9, owned there): requests over the tunnel renew the remote daemon's idle deadline, a persisting
ssh master with no requests on it does not, and ControlPersist bounds only the ssh process. mcpx must not be started remotely with
autostart semantics that surprise (daemon.endpoint today deliberately refuses to autostart a remote
daemon; the ssh case is ambiguous and should follow the same rule: fail rather than start). Artifact
sharedFs must be false over ssh, which docs/exec.md:189-193 already anticipates — remoteness is declared
by the caller, so this is a caller-side default, not an inference.

The pure-documentation variant of the same idea costs nothing and should ship regardless: OpenSSH has
forwarded unix sockets since 6.7, so ssh -N -L 7777:/path/to/daemon-<key>.sock host plus
daemon.endpoint=http://127.0.0.1:7777 works today with no code. Put it in docs/ as the zero-change
recipe. It is clunky enough (you must know the remote socket path — see the socket-path issue) that it is
not a substitute for the scheme.

Approach C — recommended: B, plus name the three cases and close the reverse-access one with a tunnel, not a shell

Recommended. Ship the ssh:// endpoint scheme from B, and alongside it settle the other two questions
explicitly rather than leaving "ssh" as one word:

1. "ssh tui" — solved by B. Document ssh host mcpx tui as the zero-config answer and
--daemon-endpoint ssh://host as the better one. Do not build a server for it. Reassess only if someone
wants a TUI on a machine that has no mcpx and cannot get one — a real but narrow case that a static
binary answers more cheaply than an SSH server does.

2. "ssh rpc" — B is the whole of it. Note that MCP-over-ssh needs no work at all: any MCP host can be
configured with command: ssh, args: [host, mcpx, serve, --transport, stdio]. This deserves a section in
docs/protocol.md because it is free and nobody will guess it. A mcpx client --ssh host generator that
emits that snippet for a given host is the useful sliver.

3. "reverse shell" — use a tunnel, do not write one. The underlying need is a daemon behind NAT that
something outside must reach. ssh -R already does this, and so does tailscale/wireguard, and both are
better at it than anything mcpx would grow: they reconnect, they authenticate, they are audited. mcpx's job
is to be easy to put behind one, which means (a) daemon.address already exists, (b) the daemon should be
able to bind an additional listener given as an address so a tunnel has a stable target, and (c) a
documented ssh -R recipe. An actual reverse shell — the daemon dialling out to a rendezvous and accepting
commands — is a backdoor with a friendly name, and the first thing an enterprise security review will
reject; it should not be built.

4. "shell" — a separate, orthogonal feature worth one line. The notes list shell beside the remote
items but it is a local gap: there is no mcpx shell REPL, only mcpx tui (full-screen, mouse-driven)
and mcpx prompt (one-shot, internal/cli/consumer.go:399). A line-oriented REPL that keeps one daemon
connection and accepts call, ls, exec lines would be cheaper than either and is what people reach for
over a slow link where a full-screen redraw hurts. It composes with B: mcpx shell --daemon-endpoint ssh://host. Tracked here so it is not lost, but it is the smallest item in the issue.

Open questions

  • Does mcpx <stdio-proxy> want to be a public subcommand (discoverable, documented) or hidden (an
    implementation detail of the ssh scheme)? Hidden risks people reimplementing it; public risks it being
    treated as a supported protocol.
  • Should ssh:// reuse daemon.endpoint, or is a separate daemon.transport setting cleaner once there
    are three schemes? Current opinion: one setting, the scheme carries it, because every command that already
    lists daemon.endpoint then gets ssh for free.
  • Over ssh, does SSE (/v1/events) behave? It is a long-lived HTTP response over a pipe; it should, but it
    is the one thing in B worth prototyping before committing.
  • If a wish-based server is ever built, does it go behind a build tag, a separate cmd/, or a separate
    flake package? (build(deps): bump build/buck/preludes/buck2-prelude from d956689 to 3cfc766 #49 is relevant: every package needs a CI build.)
  • ControlMaster by default: convenience, or a surprising process left behind? (The daemon side is
    settled by chore(deps): bump actions/setup-go from 4 to 5 #87: an idle master does not hold the daemon. What remains is whether mcpx should leave an ssh
    process on the local machine for ControlPersist seconds.)

Ease · Impact · Related

Ease: S for the documentation and the ssh host mcpx serve recipe; M for the ssh:// endpoint
scheme and stdio proxy; L for a wish-based SSH server (A), mostly in policy rather than code.
Impact: med — high for anyone with a remote or containerised daemon, nil for a single-laptop user.
Depends on / related: #88 (an ssh -L recipe needs a knowable remote socket path; #88 also owns
MCPX_SOCKET precedence, collision 8, which matters once a second machine is asking), #87 (owns the
lifetime rule this issue applies, collision 9), #76 (CLI parity for /v1-only ops — a remote TUI makes the
gaps more visible), #49 (any new package or build tag needs CI), #86 (ssh -L plus /ui/ is the remote
dashboard), #52 (closed, fixed in #182). A Containerfile now exists (#182, pkgs/mcpx/Containerfile), so
the "containerised daemon" in the Impact line is a shipped shape, not a hypothetical.

Security/auth deferred; see tracker.

Activity

  1. developing-today commented on Oct 28, 2026

    @developing-today
    Contributor
  2. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    History carried over from dezren39/darwin-nix#85 (verbatim, oldest first):


    @dezren39 · 2026-09-30T07:21:32Z

    Integration pass. Placed in the DAG rooted at #181.


    @dezren39 · 2026-09-30T07:28:13Z

    Integration pass -- cross-theme collision. Surfaced only when the themes were put side by side; the authors of these issues could not see each other's work. Recorded at the root in #181.

    Three surfaces, one lifetime question

    A browser tab versus daemon.idleExit (#86), leftover ssh ControlMaster processes (#85), and leaked e2e daemons (#52) are the same class of problem: who ends a session nobody explicitly closed. #86 says "decide deliberately" about its own case. Decide it once for all three.

    Also on: #86 #52. Nobody currently owns this.


    @dezren39 · 2026-09-30T11:05:51Z

    Collision ownership (issues pass, 2026-09-30, at 05c78b2). #181 listed these cross-theme collisions with no owner. Each now has one:

    #181 row Collision Owner What the owner decides
    9 one lifetime question on three surfaces (browser tab vs daemon.idleExit, ssh ControlMaster, leaked e2e daemons) #87 #87's touch-on-read convention is the lifetime rule: activity on any surface renews the daemon's idle deadline, and nothing else does. #85 and #86 apply it. The e2e case is closed: #182 fixed #52 (stop --all via info files, MCPX_AUTOSTART_IDLE_EXIT=60s).

    Where this issue is the owner, the decision is made here; where it is not, it adopts the owner's decision rather than making its own.


    @dezren39 · 2026-09-30T12:12:15Z

    Issues pass (2026-09-30, at 05c78b2).


    @dezren39 · 2026-10-28T21:08:19Z

    Triage verdict — confirmed as stated (two of three wants already solved by existing transports, third unbuilt); mis-homed in this repo.

    Verified against current main (2026-10-28):

    Route: no small_fix packet in the nix queue — this is a design decision for the repo that owns the daemon's transports; implementing or deciding it here would recreate the vendored-copy split, and a duplicate code filing now would violate the one-issue-per-change rule while code#309 owns the transfer. Keep open here until code#309 re-files it in developing-today/code, then close here with that link.


    agent-note:v1 · agent: repo-queue-worker-shift-nix (lane A) · task 2080 · kind: triage · outcome: confirmed, no built-in ssh/reverse-tunnel at code HEAD, existing transports verified; routed to #309 re-home; no nix follow-up packet

  3. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Oct 29, 2026
  4. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

    • Agent: queue worker (repo-worker-code), lane A shift
    • Session / ID: queue task 2773 (triage)
    • Found via: queue claim
    • Task: Code-side triage of this relocated mcpx ssh remote-access issue: dedupe against existing code issues, confirm scope against current pkgs/mcpx main, then enqueue the implementation follow-up (or split) with acceptance criteria intact. No implementation in this task.
    • Expectation: Triage verdict posted here + follow-up task(s) enqueued, or a duplicate close with links both ways.
    • Links: queue task 2773
  5. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done (triage)

    Verdict: confirmed, not a duplicate. Scope holds against current main (42796be).

    Dedupe: "mcpx ssh" search returns only this issue. #1887 (surfaces tracker) is its parent family, #1886 owns the socket-path question the ssh -L recipe depends on — neither covers this work.

    Scope check against main: code search finds no ssh:// scheme anywhere in pkgs/mcpx and no stdio proxy subcommand — daemon.endpoint still understands only http(s):// and unix://, exactly as the issue states. Approaches A (built-in wish SSH server) and the reverse shell stay refused for the reasons in the issue.

    Routing: the Approach C implementation is enqueued as one queue follow-up: the ssh:// endpoint scheme via the system ssh (DialContext + remote stdio proxy, no-autostart rule preserved, SSE-over-tunnel verified) plus the zero-change docs recipes (ssh -L, ssh host mcpx tui, MCP-over-ssh in docs/protocol.md, ssh -R). Two items named in the issue stay tracked here rather than in the follow-up: the mcpx shell REPL (separate smallest item) and the additional-listener binding for a stable tunnel target.

    • Expectation: This issue stays open for the two remaining items; the scheme + docs land via the enqueued follow-up task.
    • Links: follow-up packet dedupe repo-health:v1:fix:developing-today/code:issue:1880
  6. 28 remaining items

  7. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up (resume)

  8. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    and removed
    queue/claimedQueue worker lifecycle state
    on Oct 30, 2026
  9. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · parked

  10. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    Agent note — parked (CI pending, re-check)

    • Queue task: 3113 (Lane D, repo-worker-code). Re-claimed after the earlier park expired and re-checked PR feat(mcpx): ssh:// daemon endpoints over the system ssh binary (#1880) #2506 @ 0a9ee54: basic checks still queued repo-wide, no Codex round-3 review posted yet. All findings from rounds 1–2 remain dispositioned (round-2 fixes are in this head, with thread replies).
    • Resume: on CI green + no undispositioned Codex findings, merge and complete fixed_merged. Parked 20 min at 2026-10-30T03:28Z.
  11. added a commit that references this issue on Oct 30, 2026
  12. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done (with a follow-up fix PR)

  13. removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    work/assignedQueue worker lifecycle state
    on Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions