Skip to main content
Glama

remote-dev

A remote development substrate for coding agents. It makes a remote Linux host reachable over SSH feel like the local working tree: every native editor tool has a remote twin with the same semantics plus endpoint fields.

Install the vaws-remote-dev package. The import package is remote_dev; the console entry is remote-dev.

Local tool

Remote tool

CLI

Read

remote.read

remote-dev read

Write

remote.write

remote-dev write

Edit

remote.edit

remote-dev edit

MultiEdit

remote.multi_edit

remote-dev multi-edit

Bash

remote.bash

remote-dev bash

Glob

remote.glob

remote-dev glob

Grep

remote.grep

remote-dev grep

LS

remote.ls

remote-dev ls

apply_patch

remote.apply_patch

remote-dev apply-patch

write_stdin

remote.job_stdin

remote-dev job-stdin

Plus background jobs (remote.job_status / job_tail / job_stop / job_stdin), artifacts (remote.artifact_manifest / artifact_pull / artifact_push), and endpoint facts (remote.probe, remote.context_snapshot). Nineteen tools in total, served by one stdio MCP server (remote-dev server) and mirrored one-to-one by CLI subcommands. python -m remote_dev is equivalent to remote-dev.

Help and parser errors do not import execution backends or contact endpoints. The selected operation loads its implementation after argument parsing.

remote-dev probe --host <host> --diagnose-connection sends one fixed read-only probe for OS, working directory and Python version. It reports local preparation, SSH process duration, received TCP/authentication milestones, remote probe execution and final stream drain/exit timing. Connection time includes client startup and authentication; TCP time is a subset, not an additional phase. Absent milestones and pure transfer time remain unknown. The unattributed remainder includes channel/Python startup and transport overhead. Verbose SSH lines are interpreted locally and excluded from the returned diagnostic stderr. On a failed shared connection, the fixed probe may compare an independent connection. It never retries an arbitrary business command. Ordinary bash results also include transport timings without enabling verbose SSH tracing.

Runtime requirements: Python 3.9+ and an ssh client. No third-party packages. Nothing here needs GPU/NPU hardware; the remote host only needs bash, python3, and (for remote.apply_patch unified diffs) git.

The same public API runs from Windows, macOS and Linux clients. Attached SSH streams and local forwards launch literal argv under an owned local process group: a Windows Job Object is assigned before the child starts; POSIX uses an independent session. Timeout and close include inherited children even after SSH itself exits, and preserve available UTF-8 output. The internal launcher accepts native cwd and an environment overlay; it does not interpret shell syntax or translate Windows/WSL paths. The remote command still runs in Bash on its Linux endpoint. Local SSH cleanup does not prove remote process quiet; managed remote jobs use their supervisor's stop and quiet receipt. A POSIX child that deliberately creates a separate session is outside the local group.

Install and start the MCP server

From a git ref (no local checkout required):

uvx --from git+https://github.com/vllm-ascend-workspace/remote-dev@main remote-dev server

From a clone:

uv pip install -e .
remote-dev server

.mcp.json (Claude Code / Cursor; Kimi Code uses .kimi-code/mcp.json — see examples/kimi-mcp.example.json):

{
  "mcpServers": {
    "remote-dev": {
      "type": "stdio",
      "command": "uvx",
      "args": [
        "--from",
        "git+https://github.com/vllm-ascend-workspace/remote-dev@main",
        "remote-dev",
        "server"
      ],
      "env": {
        "REMOTE_DEV_DEFAULT_USER": "root",
        "REMOTE_DEV_DEFAULT_ROOT": "/",
        "REMOTE_DEV_DEFAULT_CWD": "/",
        "REMOTE_DEV_RESOLVERS": "/absolute/path/to/consumer/remote_dev_plugin.py:setup"
      }
    }
  }
}

If the package is already installed in the client environment, command can be remote-dev with args: ["server"]. More client examples live in examples/. See CLIENT_COMPATIBILITY.md for per-client notes.

Related MCP server: ssh-mcp

Native-habit compatibility layer

One execution kernel serves every client; the differences live in a thin, shared parameter layer (remote_dev.mcp.schemas.normalize_arguments, applied by both the MCP dispatcher and the CLI --input-json path):

  • Aliases: path for file_path (read/write/edit/multi_edit), line_offset/n_lines for offset/limit (read), cmd/workdir for command/cwd (bash), session_id for job_id, -i/-A/-B/-C/-n/head_limit (grep). A canonical key always wins; unknown keys pass through. Fields with aliases are enforced by the server, not the wire schema's required list, so alias-only calls pass provider-side validation.

  • remote.read accepts a negative offset to read from the end of the file (offset=-100 reads the last 100 lines). Binary files (NUL byte in the first 8192 bytes) fail with an actionable binary_file status — there is no remote image/media preview; use remote.bash or remote.artifact_pull.

  • remote.write gains append (Kimi mode=append): extends the file atomically, creates it when missing, and is mutually exclusive with overwrite.

  • remote.grep gains case_insensitive, context_lines/before_context/ after_context, a line_numbers toggle, offset pagination, and include_ignored (rg --no-ignore --hidden; the grep fallback only lifts its hidden/.git excludes and says so). output_mode distinguishes count (matching lines per file, rg -c) from count_matches (total matches per file, rg --count-matches; the grep fallback counts -o matches per file). They differ whenever one line holds several matches.

  • remote.bash command=... starts with writable stdin and waits up to yield_time_ms (default 10000) for output or completion. A live process or unread output returns session_id; completion reports exit_code and quiet. The wait excludes SSH connection and process preparation. Omitted timeout_ms (or zero) means no command deadline; an explicit value limits remote execution. run_in_background, interactive, and the separate monitor tool have been removed.

  • remote.job_stdin session_id=... chars=... writes input; empty chars polls any session, including completed sessions with unread output. Per-stream byte cursors advance under a cross-process lock, preserving Unicode and preventing concurrent polls from replaying bytes. A partial input write reports written_chars; resend that exact remainder. EOF is deferred until all submitted characters are accepted.

  • tty=true allocates a real 24x80 remote PTY with merged stdout/stderr. Ctrl-C signals the terminal foreground group. eof=true sends terminal Ctrl-D (canonical terminal semantics); pipe EOF closes stdin. In pipe mode, control bytes remain data. remote.job_stop stops the owned process family.

  • max_output_tokens budgets approximately four UTF-8 bytes per token across both text and structured previews, shared by stdout/stderr. Each preview has a four-byte minimum so one Unicode character can progress. Status/refs metadata is separate. Unreturned bytes remain at the cursor; full decoded logs accumulate in local refs as pages are consumed.

  • Developer calls reuse one binary SSH stdio connection on Windows and POSIX, independently of OpenSSH ControlMaster. Concurrent first calls to the same endpoint share startup; different endpoints do not hold a global startup lock. The 32-connection pool evicts idle LRU entries automatically and expires idle connections after five minutes (reaped within another minute). Busy connections are never evicted; capacity waits respect cancellation and the request deadline. Both MCP and remote RPC reserve two workers/eight slots for status, stop, tail and short stdin exchanges, alongside eight ordinary workers/32 slots. Long waits cannot consume that control capacity. A lost reply remains an unknown outcome and is never automatically replayed.

  • Explicit runtime_env_file runs once per command in that command's Bash process, preserving functions, non-exported variables, PATH order and shell options. Missing scripts or failed initialization prevent the user command. Normal Bash startup (including BASH_ENV and SSH .bashrc behavior) is retained; arbitrary dynamic initialization is never cached.

  • Managed callers using processes.control(..., "prepare", spec=...) can set prepared_timeout_seconds (default 120, between 1 and 86400 seconds) for their bounded queue/activation wait. Expiration cancels the unopened gate without running user code. Command timeout_seconds starts after activation; a lease heartbeat does not implicitly extend the remote prepared deadline.

  • Tool arguments outside the published schema, native aliases and registered endpoint selectors are rejected before execution. MCP remote.bash uses yield_time_ms and continuation through session_id; wait=True is an SDK option and is rejected on the MCP surface.

  • Developer MCP and CLI tools choose pooled connections and keepalives internally. Their endpoint arguments no longer include ssh_mux, keepalive or --long-stream; low-level Python transport callers retain Endpoint policy and Endpoint.for_long_stream. MCP failure text includes the status and recovery detail for clients that do not read structured results.

  • Reads scan in bounded memory. verify_content=false (CLI --no-verify-content) stops after a positive-offset log window and omits the hash/read ledger; the default retains a full hash and exact line count for guarded editing. Search results stream to a bounded page. Artifact batches use one SSH stream with 1 MiB chunks, checksum verification and atomic file replacement. Default probes avoid importing application modules; request explicit modules (CLI --module) when needed.

The endpoint-explicit contract

remote-dev resolves endpoints from explicit fields and nothing else:

Field

Default

Meaning

host

required

SSH host

port

required

SSH port

user

root

SSH user (REMOTE_DEV_DEFAULT_USER)

root

/

Path-policy root (REMOTE_DEV_DEFAULT_ROOT)

cwd

same as root

Default working dir (REMOTE_DEV_DEFAULT_CWD)

runtime_env

true

Source runtime_env_file before commands

runtime_env_file

unset

Remote profile script (REMOTE_DEV_RUNTIME_ENV_FILE)

identity_file

unset

SSH private key

connect_timeout_ms

10000

SSH connect timeout

alias

unset

Name from the endpoint alias files

The Python Endpoint API additionally accepts ssh_mux and keepalive for low-level transport callers; these are not developer-tool arguments.

Resolution order in remote_dev.core.endpoint.resolve_endpoint:

  1. host + port given: use them directly.

  2. alias given: look it up in the alias files (REMOTE_DEV_ENDPOINTS_FILE, then endpoints.json and endpoints.local.json in the process cwd; both are git-ignored) and let explicit caller fields override the alias entry.

  3. Otherwise ask each registered resolver plugin, in registration order.

  4. Nothing claimed the payload: fail with EndpointError listing the known selector fields and registered resolvers. remote-dev never guesses a target from the working directory or from files it does not own.

remote.job_status, remote.job_tail and remote.job_stop can rebuild their endpoint from the local job record and therefore resolve only when the caller supplies a selector.

Permission model

Direct endpoints default to full remote-path permission (root=/) with the same path as the default cwd. Path containment, symlink checks and cwd validation are still enforced, but against / unless a narrower root is set. Pass --root /srv/app --cwd /srv/app when a task requires path isolation. A consumer that wants a project default cwd sets REMOTE_DEV_DEFAULT_CWD; remote-dev does not assume a workspace tree. Hook guards (remote_dev.hooks) default to allow and only observe; they are the place to add policy if you need it.

Read ledgers are optional optimistic-concurrency checks scoped by client_context_id, then CLAUDE_SESSION_ID, CODEX_SESSION_ID, CODEX_RUN_ID, REMOTE_DEV_SESSION_ID. The MCP server sets a process-local REMOTE_DEV_SESSION_ID at startup. Each nonempty effective context id is stored under one filesystem-safe directory id-<sha256 of the raw id>. The no-context fallback remains default and does not collide with a caller who explicitly supplies that word. A ledger is never required for an edit or write of a file this context has not previously read. After a successful read, later writes in that context are checked against the recorded SHA. Ledgers written by earlier encodings are kept in place. A write or edit that finds only those older files — including a v1 record occupying the current id-<sha256> or default path — returns structured read_required until the same context reads the file again. Path or ledger_scope matching the current computed string is not enough: only a record written under the current encoding (schema remote-dev.read_ledger.v2) authorizes a later write. A legacy shared SHA is not authorization for a distinct context.

Resolver plugin interface

Anything that is not host/port/alias - session registries, machine inventories, worktree bindings, coordinator leases - is consumer knowledge. The consumer registers a resolver with remote-dev; remote-dev never imports the consumer.

# consumer/remote_dev_plugin.py
from remote_dev.core.endpoint import EndpointError, register_resolver, resolver_setup

def by_lab(payload):
    lab = payload.get("lab")
    if not lab:
        return None                      # not ours: next resolver, please
    record = my_inventory.load(lab)      # consumer-owned lookup
    if record is None:
        raise EndpointError(f"unknown lab {lab!r}")
    return {
        "host": record.host, "port": record.ssh_port,
        "cwd": record.root,
        "runtime_env_file": "/etc/profile.d/toolchain.sh",
        "kind": "lab-endpoint",
        "source": {"lab": lab},
    }

@resolver_setup
def setup():
    register_resolver(by_lab, name="labs", fields=("lab",))

Contract:

  • resolve(payload) -> dict | Endpoint | None. Return None to decline. A dict needs host and port; remote-dev builds the Endpoint, lets explicit caller fields (user, root, cwd, runtime_env, runtime_env_file, identity_file, connect_timeout_ms, ssh_mux, keepalive) override the resolver's values, sets kind to resolver:<name> unless provided, and records source.resolver.

  • fields declares the payload keys the resolver claims. They are added to selector_fields() so has_selector() and the job tools treat them as "an endpoint was requested". Tool schemas keep additionalProperties: true, so consumer keys pass through MCP clients untouched.

  • Resolvers are consulted even for an empty payload. A consumer that wants "zero-argument inside my worktree" behaviour implements it in its resolver (return None when there is no binding).

  • Raise EndpointError when you claim a payload but cannot resolve it. Other exceptions are wrapped into EndpointError with the resolver name.

Loading into a process you do not control (the MCP server is spawned by the client, CLI wrappers by a shell):

REMOTE_DEV_RESOLVERS="/abs/path/consumer/remote_dev_plugin.py:setup"
REMOTE_DEV_RESOLVERS="consumer.remote_dev_plugin:setup,other.pkg:setup"

Entries are module:callable or /path/file.py:callable. A callable marked with @resolver_setup is invoked once and registers what it likes; any other callable is registered directly under its spec string. A broken entry fails every resolution with the same message instead of degrading to "no resolvers". examples/resolver_plugin.py is a complete runnable example.

Programmatic embedding works too: from remote_dev.core.endpoint import register_resolver before invoking remote_dev.mcp.tools.call_tool.

The public result envelope is remote_dev.result (schema_version: remote-dev.result.v1). Schema JSON ships as package data.

Selector keys on the CLI travel through --selector KEY=VALUE:

remote-dev bash --selector lab=gpu-1 --command 'nproc'

What this repository does not hold

  • No consumer state. state/ (job records, read ledgers, logs, artifact manifests) is git-ignored and relocatable with REMOTE_DEV_STATE_DIR. The default is <cwd>/state.

  • No endpoint data. endpoints.json / endpoints.local.json are git-ignored; ship aliases from your own tree via REMOTE_DEV_ENDPOINTS_FILE. examples/endpoints.example.json shows the shape.

  • No knowledge of any particular consumer: no session registries, machine inventories, coordinators, task facades, or skill catalogues. Those live in the consumer and reach remote-dev only through resolvers and environment variables listed below.

Configuration reference

Variable

Purpose

REMOTE_DEV_DEFAULT_USER

Default user (root)

REMOTE_DEV_DEFAULT_ROOT

Default root (/)

REMOTE_DEV_DEFAULT_CWD

Default cwd (unset = same as root)

REMOTE_DEV_RUNTIME_ENV_FILE

Default runtime_env_file (unset = no preamble)

REMOTE_DEV_RESOLVERS

Comma-separated resolver plugin specs

REMOTE_DEV_ENDPOINTS_FILE

Alias file(s), os.pathsep separated, read first

REMOTE_DEV_STATE_DIR

Local state directory (default <cwd>/state)

REMOTE_DEV_SSH_MUX_DIR

OpenSSH ControlMaster dir (default ~/.ssh/remote-dev-mux)

REMOTE_DEV_SSH_MUX

Process-wide SSH multiplexing default on POSIX: unset or 1 uses the shared ControlMaster; 0 forces independent connections; other values error. Native Windows has no Client ControlMaster (Win32-OpenSSH); ordinary connections already use the independent triple and do not need this flag. ssh_mux=True / REMOTE_DEV_SSH_MUX=1 on native Windows is a capability error. An endpoint's ssh_mux overrides the process default on POSIX.

REMOTE_DEV_SESSION_ID

Read-ledger scope when no client id is given

For low-level Python SSH calls, REMOTE_DEV_SSH_MUX is the process-wide default and is read without changing global SSH configuration or the shared ControlMaster socket. Leave it unset or set it to 1 to keep today's shared-mux path, including the per-identity ControlPath suffix. Set it to exact 0 in a Python process that must not join the shared master (ControlMaster=no, ControlPath=none, ControlPersist=no on every SSH invocation from that process that does not set ssh_mux). Accepted values are unset, 1, and 0; any other value is a configuration error. Developer MCP/CLI operations use their own pooled independent connections with keepalives, regardless of this low-level default. Native Windows CLI and MCP pipes use UTF-8. Shell script uploads preserve LF bytes, and nested artifact paths use POSIX separators on the Linux peer.

On native Windows the transport chooses independent connections by itself because Win32-OpenSSH does not implement Client ControlMaster; do not set ssh_mux=true or REMOTE_DEV_SSH_MUX=1 there.

A single Python process may do both at once. Set ssh_mux=false on the endpoints that must stay off the shared master, and leave the rest on the default. This is not optional for long-lived connections such as ssh -N -L tunnels: ControlMaster delegates -N forwards to the mux master and the client exits rc=0 immediately, tearing the tunnel down. OpenSSH first-option-wins semantics make a later ControlMaster=no override ineffective, so the independent triple has to be chosen before the command is built. ControlMaster=no alone is not enough — a client can still attach to an existing ControlPath.

Low-level keepalive=true adds ServerAliveInterval=30 and ServerAliveCountMax=10. That is a mechanism flag, orthogonal to mux, and conditional rather than always-on: a slow multi-hour stream otherwise dies to an idle timeout somewhere in the path, but attaching ServerAlive to short multiplexed commands would set TCP keepalive policy on the shared ControlMaster (the master owns the TCP connection; first-option-wins).

Hour-scale streams and ssh -N -L tunnels use one named entry point: Endpoint.for_long_stream(host, port, ...). It always sets ssh_mux=False and keepalive=True and cannot be half-configured (ssh_mux=True is refused). run_stream / stream_ssh_command refuse any endpoint that would still attach to a ControlMaster — the silent failure this project recorded is rc=0 with the tunnel gone, so a docstring is not a control.

Live streaming is the library function remote_dev.core.ssh_transport.run_stream. It stays attached, forwards output as it arrives, and enforces a timeout on both sides (remote timeout --preserve-status plus a local deadline-bounded reader: select on POSIX, reader threads on native Windows). It returns RemoteCompleted (returncode, not exit_code) and does not emit remote-dev.result.v1. It is not remote.job_*. Scripts travel through binary stdin instead of command-line arguments, so large generated scripts work on native Windows. Upload and output draining run concurrently under the same local timeout.

Detached background work uses one process implementation: remote_dev.processes.control(endpoint, job_id, action, **parameters). Actions are prepare, go, status, tail, stop, stdin, launch, and exchange. The Linux worker is a child-subreaper with identity/marker checks, a start gate, descendant drain, and timeout/stop. Ordinary remote.bash and remote.job_* call this same boundary. Coordinator may call it directly with an explicit host+port mapping; remote-dev does not load coordinator state. The worker is Linux-only; the local transport client supports macOS, Linux, and native Windows.

Python consumers needing a completed result call remote_bash(..., wait=True). This waits and drains full log refs through the same supervisor, with stdin closed unless a PTY is requested. Coordinator retains its separate prepare/authorized go gate for managed execution.

Two more transport primitives close the remaining SSH-option gaps. They are library APIs, not MCP tools, and they do not accept extra -o strings.

  • open_local_forward(endpoint, remote_port) opens ssh -N -L on the for_long_stream shape (ControlMaster=no, ControlPath=none, ControlPersist=no, keepalives, ExitOnForwardFailure=yes). It refuses a multiplexed endpoint the same way run_stream does. The handle exposes local_port, wait_ready(timeout_s), and close() (process-group kill; a forward that dies is never reported as rc=0 — that silent success is the recorded mux-absorbed -N failure).

  • run_interactive(endpoint, remote_command) is a one-off TTY-inherited bootstrap (BatchMode=no, password/keyboard-interactive only, PubkeyAuthentication=no). Combining it with multiplexing is impossible: a password prompt through a ControlMaster is meaningless and hangs. interactive_ssh_command returns the argv for wrappers such as SSH_ASKPASS. This is first-contact bootstrap, not a general PTY facility.

Stdin bytes into a remote command are already run_bytes. Detached background work is remote.bash --run-in-background / remote.job_*. Directory trees move with remote.artifact_push / artifact_pull.

MCP server and clients

remote-dev server speaks JSON-RPC over stdio with Content-Length framing (newline-delimited JSON is accepted as a test fallback). Discovery advertises portable underscore names (remote_read, ...); dotted canonical names remain accepted on tools/call. Resources:

  • remote://endpoints

  • remote://endpoint/<endpoint-id>/context/latest

  • remote://endpoint/<endpoint-id>/jobs

  • remote://endpoint/<endpoint-id>/job/<job-id>/{status,stdout,stderr}

  • remote://endpoint/<endpoint-id>/artifacts

  • remote://endpoint/<endpoint-id>/artifacts/<artifact-id>/manifest

Every tool returns {"text": ..., "result": ...} where result follows remote_dev.result / the packaged result.schema.json (remote-dev.result.v1): outcome in success | needs_input | blocked | failed | timeout | cancelled, a compact preview, and refs to full logs on disk. Model-visible text is capped; full output is reachable through refs and MCP resources.

Validation

uv pip install -e ".[test]"
python -m pytest
remote-dev validate --local-only

Live checks need a reachable SSH host:

remote-dev validate --host <host> --port <port> --root /srv/app --cwd /srv/app
remote-dev validate --alias lab --skip-local

The validator compile-checks the installed package, reports MCP/CLI burden metrics, and (with an endpoint) exercises read/edit/write/bash/search, patches, artifacts, background jobs, MCP resources and parallel scratch workers, then cleans up after itself.

Layout

remote_dev/  installable package (core, processes, mcp, hooks, tools, schemas)
tests/       unittest suite collected by pytest (mocked transports, no SSH)
examples/    client configs, alias file shape, resolver plugin

See DESIGN.md for the architecture and VALIDATION.md for the evidence record.

License

MIT. See LICENSE.

Runtime feedback and connection checks

MCP responses report the process-start package identity separately from the currently installed distribution. A restart_required status means the native client must restart that MCP server; changing installed files does not reload it. The source commit is unknown when distribution metadata does not provide it.

remote-dev probe --host HOST --port PORT --diagnose-connection runs a fixed, read-only SSH probe. After a failed multiplexed probe it compares an independent connection, and may suggest ssh_mux=false. It never replays the business command or changes global SSH configuration. Foreground bash results expose the actual connection mode and timeout. A timeout or exit 255 leaves remote outcome unknown.

remote_dev.diagnostics.open_http selects direct or environment proxy use per call. Its companion diagnostics strip URL credentials and query parameters, and distinguish HTTP status from DNS, timeout and connection errors.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables agent-style remote development over SSH with file operations and command execution, exposing native tools for searching, editing, and transferring files on remote machines.
    1
    -
  • A
    license
    A
    quality
    C
    maintenance
    Enables reading, writing, editing, searching, running commands, transferring files, and using git on a remote Linux server over SSH via MCP tools.
    22
    3
    AGPL 3.0
  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables secure remote workspace management over SSH, supporting file operations, shell commands, and profile configuration for agent-safe remote code environments.
    -
  • A
    license
    Not graded
    quality
    A
    maintenance
    Gives MCP-compatible AI clients safe, hands-on access to local codebases: file reading/search, multi-file patches, command execution, interactive sessions, Git inspection, and coordination of local agent providers such as Antigravity and OpenCode.
    18
    Apache 2.0