Repository navigation
Description
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:
ssh host mcpx <anything>already works.ssh host mcpx tuiis an SSH TUI.ssh host mcpx serve --transport stdiois MCP-over-SSH and is a validcommandfor any MCP client's stdio transport, today,
with no mcpx change. What does not work is talking to a remote daemon from a localmcpxwhen the
remote is only reachable over ssh:daemon.endpointunderstandshttp(s)://andunix://and nothing
else.- mcpx has five direct Go dependencies and the package file treats that as a design property
(pkgs/mcpx/package.nix:36-53enumerates and justifies each one).charmbracelet/wishv1.4.7 adds
~15 modules to the graph includinggo-git/go-git/v5,ProtonMail/go-cryptoandcloudflare/circl
(verified fromhttps://proxy.golang.org/.../wish/@v/v1.4.7.mod). Unused packages are dropped by the
linker, butgo.sumand the nixvendorHashare not, and mcpx currently has nogolang.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-boxis 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 lsall point at the remote daemon for free — they already
honourdaemon.endpoint(internal/settings/registry.go:361-362lists 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://reusedaemon.endpoint, or is a separatedaemon.transportsetting cleaner once there
are three schemes? Current opinion: one setting, the scheme carries it, because every command that already
listsdaemon.endpointthen 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 fromd956689to3cfc766#49 is relevant: every package needs a CI build.) ControlMasterby 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 forControlPersistseconds.)
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
developing-today commentedon Oct 28, 2026
CODE-807
dezren39 commentedon Oct 28, 2026
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.
d956689tofdddce8#86 chore(deps): bump actions/setup-go from 4 to 5 #87 chore(deps): bump build/buck/preludes/buck2-prelude fromd956689tofd4ce63#88@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 sshControlMasterprocesses (#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:
daemon.idleExit, sshControlMaster, leaked e2e daemons)stop --allvia 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).
Parent: #174. TheControlMasterdiscussion now applies chore(deps): bump actions/setup-go from 4 to 5 #87's lifetime rule (Phase 3: sync, blob gate, and relays #181 collision 9, owned there). An idle ssh master does not hold the daemon, andControlPersistbounds only the ssh process.d956689toe7457f6#52 is marked closed (fixed in Phase 5: tag event bodies must travel with the event #182). TheMCPX_SOCKETpointer now goes to chore(deps): bump build/buck/preludes/buck2-prelude fromd956689tofd4ce63#88 (collision 8) rather than fix(deps): update rust crate leptos_router to v0.8.17 #78. Added chore(deps): bump actions/setup-go from 4 to 5 #87 and chore(deps): bump build/buck/preludes/buck2-prelude fromd956689tofdddce8#86, and noted that a Containerfile now exists (Phase 5: tag event bodies must travel with the event #182).tui.go:41-51,data.go:19-28,registry.go:350-363/:343-375/:361-362,client.go:130-160,serve.go:358-383,package.nix:36-53,consumer.go:399. Added thedesignlabel.@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):
dezren39/nix@8c42c2c, origin/main) has no mcpx Go source —pkgs/mcpx/deleted 2026-10-19 (lab-ops-hub container crawler accepts inet_aton IPv4 spellings (127.1, 2130706433, 0x7f.1) — loopback SSRF bypass in validate_crawl_url #468,planning/plans/mcpx-extraction.mdsteps 4–5); mcpx lives solely indeveloping-today/codeatpkgs/mcpx/.developing-today/code@528886e, origin/main), the transports this issue credits still exist:daemon.endpointatpkgs/mcpx/internal/settings/registry.go:429anddaemon.address(default127.0.0.1) at:444.git grep -i ssh pkgs/mcpx/internal pkgs/mcpx/cmdat code HEAD finds no SSH server, no reverse-tunnel/reverse-shell implementation — only the acknowledgement this issue quotes, now atpkgs/mcpx/docs/exec.md:205("A unix socket can be ssh-forwarded") andpkgs/mcpx/internal/execsvc/execsvc.go:70. So the issue's framing stands: SSH-TUI and SSH-RPC are compositions of existing pieces, inbound NAT access is the only genuinely new work, and no built-in SSH server has appeared since.Route: no
small_fixpacket 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 indeveloping-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
dezren39 commentedon Oct 29, 2026
🤖 Agent note · picked up
dezren39 commentedon Oct 29, 2026
🤖 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 -Lrecipe 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.endpointstill understands onlyhttp(s)://andunix://, 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: themcpx shellREPL (separate smallest item) and the additional-listener binding for a stable tunnel target.repo-health:v1:fix:developing-today/code:issue:188028 remaining items
dezren39 commentedon Oct 30, 2026
🤖 Agent note · picked up (resume)
fix(mcpx): disposition Codex round-2 findings on the ssh transport (#…
dezren39 commentedon Oct 30, 2026
🤖 Agent note · parked
dezren39 commentedon Oct 30, 2026
Agent note — parked (CI pending, re-check)
Merge pull request #2506 from developing-today/queue/3113-1880-ssh-en…
dezren39 commentedon Oct 30, 2026
🤖 Agent note · done (with a follow-up fix PR)
bb9202c9.basic: testshowed 3 failures vs clean main's 2. Proven on the laptop: at PR head0a9ee541,internal/defaultsfailsTestNoPackageRedeclaresADefault(transport_ssh.go's inlineconst sshWaitDelay = 3 * time.Second) in addition to main's two pre-existing docs reds (TestEveryDocumentationLinkResolves,TestEveryDocumentHasAChapter); clean main fails only those two. The merge went through because onlybasic: proxy port fallbacksis a required check — main is red on the guard test now.cf968d7a): the value moves todefaults.json(daemon.sshWaitDelay) with adefaults.SSHWaitDelayaccessor, following the tsnetUpTimeout pattern. Verified on the laptop: guard test passes, defaults/settings back to exactly main's two reds, daemon SSH tests pass.