# PTY MCP
`pty-mcp` is an MCP server for managing local PTY sessions and SSH-backed remote workflows over stdio. It is meant for MCP clients (Claude Code, Codex, OpenCode, etc.) that need persistent terminal and SSH state instead of one-shot shell execution.
With `pty-mcp`, a client can:
- start a terminal session once and keep using it across multiple calls
- read buffered output incrementally instead of losing process state between commands
- send follow-up input into the same local or remote shell
- manage SSH connections, remote sessions, remote files, and remote directories through one MCP surface
- mount a remote project locally and combine local editing with remote execution
- expose mount-setup resources so an agent can guide the user through local FUSE/`sshfs` installation when mount support is missing
This makes workflows like dev servers, watch tasks, remote debugging, and near-local remote development much easier to drive.
## Installation
Using Cargo:
```bash
cargo install pty-mcp
```
Using Homebrew:
```bash
brew tap observerw/tap
brew install observerw/tap/pty-mcp
```
From source:
```bash
cargo build --release
```
The binary will be available at `target/release/pty-mcp`.
## Usage
Add the MCP server to your Codex config:
```toml
[mcp_servers.pty]
command = "pty-mcp"
```
If you want to run a locally built binary instead:
```toml
[mcp_servers.pty]
command = "/absolute/path/to/pty-mcp/target/release/pty-mcp"
```
The server communicates over stdio and reads configuration from environment variables.
## Typical Workflows
These flows are best understood from the MCP client's point of view: an agent receives a task, decides whether it needs persistent terminal state, remote access, or a mounted workspace, and then keeps interacting with the same session instead of starting over each turn.
### Scenario: keep a local dev process alive across turns
Use this when the agent needs to start something like `pnpm dev`, `cargo watch`, a test watcher, or a REPL, then come back later to inspect logs or send more input.
```mermaid
flowchart LR
A["Agent receives a local task<br/>Run a dev server / watcher / REPL"] --> B{"Does the task need process state<br/>to survive across turns?"}
B -- "Yes" --> C["pty_spawn<br/>Start one persistent local shell/process"]
C --> D["pty_read<br/>Inspect startup logs and current state"]
D --> E{"Need to interact,<br/>retry, or provide more input?"}
E -- "Yes" --> F["pty_write<br/>Send command, keystrokes, or stdin"]
F --> D
E -- "No, just observe" --> G{"Still running?"}
G -- "Yes" --> D
G -- "Finished / should stop" --> H["pty_wait or pty_kill"]
```
### Scenario: investigate or operate on a remote machine interactively
Use this when the agent needs a real remote shell that it can return to, instead of one-shot SSH execution with no retained terminal state.
```mermaid
flowchart LR
A["Agent receives a remote task<br/>Check logs / run deployment / debug service"] --> B["ssh_connect<br/>Establish or reuse SSH connection"]
B --> C{"Need an interactive remote shell<br/>with persistent terminal state?"}
C -- "Yes" --> D["ssh_session_spawn<br/>Create remote PTY session"]
D --> E["pty_read<br/>Read remote output incrementally"]
E --> F{"Need follow-up commands,<br/>answers, or shell input?"}
F -- "Yes" --> G["pty_write<br/>Continue in the same remote session"]
G --> E
F -- "No" --> H{"Task complete?"}
H -- "Not yet" --> E
H -- "Yes" --> I["pty_kill if needed<br/>ssh_disconnect when done"]
```
### Scenario: edit locally, execute remotely
Use this when the agent wants local-quality file access for search and editing, while still running build, test, or runtime commands on the remote host.
```mermaid
flowchart LR
A["Agent receives a remote code task"] --> B["ssh_connect"]
B --> C{"Would local editing/search be easier<br/>if the remote project were mounted?"}
C -- "Yes" --> D["ssh_mount<br/>Expose remote project as local files"]
D --> E["Agent reads, searches, and edits mounted files locally"]
E --> F["ssh_session_spawn or ssh_exec<br/>Run commands on the remote host"]
F --> G["pty_read<br/>Inspect build/test/runtime output"]
G --> H{"Need another edit or rerun?"}
H -- "Yes" --> E
H -- "No" --> I["ssh_unmount and ssh_disconnect"]
```
## Tool Surface
### PTY tools
- `pty_spawn`: start a local PTY process
- `pty_write`: send input to a running PTY session
- `pty_read`: page through retained output, optionally filtering by regex pattern
- returns a single `page.text` string instead of per-line objects
- `pty_list`: list known PTY sessions
- `pty_kill`: stop a PTY session with `sigint`, `sigterm`, or `sigkill`
- `pty_wait`: wait for a PTY session to exit
### Output Model
PTY reads and captured startup output now use one canonical page shape:
- `offset`
- `returned`
- `has_more`
- `total_lines`
- `text`
Default behavior is agent-first and compact:
- use `text` without line numbers by default
- request `line_number_mode=embedded` only when you need exact references
- embedded numbering prefixes each returned line as `<line_number>\t<text>`
- per-line object arrays are no longer returned
- `capture_limit` is the only switch that enables `initial_output`
`pty_read` and initial output capture support three `output_view` values:
- `plain`: ANSI stripped text
- `ansi`: ANSI-preserving text
- `raw`: raw buffer view
`line_number_mode` supports:
- `none`: return plain `text`
- `embedded`: prefix each returned line as `<line_number>\t<text>`
Constraint:
- `output_view=raw` cannot be combined with `line_number_mode=embedded`
### SSH tools
- `ssh_connect`: create or reuse an SSH connection handle
- provide `host` or `host_alias`
- required `auth_kind` values: `ssh_agent`, `identity_file`, `config_alias`
- `ssh_list`: list SSH connections and mounts
- `ssh_session_spawn`: start a remote PTY session over an existing SSH connection
- optional `capture_wait_ms`: wait briefly for initial remote PTY output before returning `initial_output`
- optional `capture_limit`: cap how much remote PTY output is captured and included in `initial_output`
- optional `output_view`: choose the format of captured `initial_output` (`plain`, `ansi`, or `raw`)
- optional `line_number_mode`: choose `none` or `embedded` for captured `initial_output.text`
- `ssh_run`: run a one-shot remote script and return `stdout`, `stderr`, and exit status directly
- optional `max_output_bytes`: cap combined captured output, default `262144`
- `ssh_exec`: run a remote script over an existing SSH connection
- use this when you want the result attached to a PTY session for later `pty_wait` / `pty_read`
- optional `wait_timeout_ms`: wait briefly for the script to finish and return completion state and exit fields inline
- optional `capture_limit`: return `initial_output` using the same compact page model
- if the script does not finish within that window, use `pty_wait` and `pty_read` with the returned `session_id`
- `ssh_read_file`: read a UTF-8 text file from the remote host
- optional `max_bytes`: allowed range `1..=524288`, default `131072`
- `ssh_write_file`: write a UTF-8 text file to the remote host
- `content` must be UTF-8 text and is capped at `262144` bytes
- `ssh_list_dir`: list one remote directory level
- `ssh_mkdir`: create a remote directory
- `ssh_mkdir.create_parents`: create parent directories as needed
- `ssh_mount`: mount a remote path locally through `sshfs`
- `ssh_mount.target_path`: local mount target path
- `ssh_mount.create_target`: create the local mount target directory when needed
- `ssh_mount.remote_path`: accepts an absolute path, `~`, or `~/...`; home-relative inputs are resolved over SSH before mounting, and mount responses/resources store the resolved absolute path
- `ssh_unmount`: unmount a mounted remote path
- `ssh_unmount.cleanup_target`: remove the target path only when cleanup is allowed
- `ssh_disconnect`: disconnect and optionally clean up related resources
#### SSH mount requirements
`ssh_mount` depends on the local machine being able to mount a remote filesystem.
To use it locally, you need:
- a FUSE implementation installed and available
- `sshfs` installed and available in `PATH`, or configured via `PTY_MCP_SSHFS_BIN_PATH`
In practice:
- macOS: `macFUSE` and `sshfs`
- Linux: `fuse` or `fuse3`, plus `sshfs`
Without local FUSE support and `sshfs`, SSH connections and remote command execution can still work, but `ssh_mount` will not.
On macOS, `ssh_mount` also adds `sshfs` mount options that suppress AppleDouble files (`._*`) and Apple extended attributes by default. This helps avoid writing those forms of Apple metadata back into the remote tree in the common case, but does not prevent Finder from creating `.DS_Store` files.
If Finder or `cp` reports metadata or permission errors while copying into a mounted path, disable that metadata-blocking mount behavior with `PTY_MCP_SSH_MACOS_BLOCK_APPLE_METADATA=false`.
Agents can read the built-in mount setup resources and **walk the user through the correct FUSE/`sshfs` installation steps** for the current platform.
## MCP Resources
The server also exposes structured resources:
- `pty://sessions`
- `pty://sessions/{id}`
- `pty://sessions/{id}/buffer`
- `pty://sessions/{id}/tail`
- `ssh://connections`
- `ssh://connections/{id}`
- `ssh://mounts`
- `ssh://mounts/{id}`
- `ssh://docs/mount-setup`
- `ssh://docs/mount-setup/{platform}`
These are useful when the client wants a snapshot without invoking a tool.
The SSH mount setup guides are meant for agents: when `sshfs`/FUSE support is missing, the
agent can read these resources and then guide the user through the right local installation flow
for the current platform instead of guessing.
## Runtime Requirements
### PTY
Local PTY support is built in. Commands are subject to policy checks for:
- allowed working-directory roots
- allowed/denied commands
- allowed/denied environment variables
### SSH
SSH features depend on host binaries:
- `ssh` is required for SSH connections and remote execution
- `sshfs` is required for `ssh_mount`
- `umount` is used for unmounting
- `diskutil` is additionally probed on macOS
On macOS, the server also probes `macFUSE` / `osxfuse` availability as part of SSH mount capability detection.
## Configuration
All configuration is read from environment variables at startup.
### Core settings
- `PTY_MCP_SESSION_LIMIT`: max number of tracked PTY sessions, default `32`
- `PTY_MCP_DEFAULT_READ_LIMIT`: default line count for reads, default `200`
- `PTY_MCP_MAX_BUFFER_LINES`: retained lines per session buffer, default `50000`
- `PTY_MCP_ALLOWED_CWD_ROOTS`: colon-separated allowed working-directory roots, default current directory
- `PTY_MCP_ALLOWED_COMMANDS`: comma-separated allowlist of command names
- `PTY_MCP_DENIED_COMMANDS`: comma-separated denylist of command names
- `PTY_MCP_ALLOWED_ENV_VARS`: comma-separated allowlist of env var names
- `PTY_MCP_DENIED_ENV_VARS`: comma-separated denylist of env var names
By default, the following env vars are denied:
- `LD_PRELOAD`
- `LD_LIBRARY_PATH`
- `DYLD_INSERT_LIBRARIES`
- `DYLD_LIBRARY_PATH`
### SSH settings
- `PTY_MCP_SSH_BIN_PATH`: explicit path to `ssh`
- `PTY_MCP_SSHFS_BIN_PATH`: explicit path to `sshfs`
- `PTY_MCP_UMOUNT_BIN_PATH`: explicit path to `umount`
- `PTY_MCP_DISKUTIL_BIN_PATH`: explicit path to `diskutil`
- `PTY_MCP_SSH_MANAGED_MOUNT_ROOT`: managed local root for SSH mounts
- `PTY_MCP_SSH_ALLOWED_HOSTS`: comma-separated host allowlist, supports `*` and `*.example.com`
- `PTY_MCP_SSH_DENIED_HOSTS`: comma-separated host denylist
- `PTY_MCP_SSH_ALLOWED_USERS`: comma-separated SSH user allowlist
- `PTY_MCP_SSH_ALLOWED_AUTH_KINDS`: comma-separated auth allowlist, values: `config_alias`, `ssh_agent`, `identity_file`
- `PTY_MCP_SSH_ALLOW_EXPLICIT_MOUNT_PATHS`: whether arbitrary local mount paths are allowed, default `true`
- `PTY_MCP_SSH_ALLOWED_MOUNT_ROOTS`: colon-separated allowed local mount roots
- `PTY_MCP_SSH_MACOS_BLOCK_APPLE_METADATA`: on macOS, whether `ssh_mount` adds `noappledouble` and `noapplexattr`, default `true` on macOS and `false` elsewhere
- `PTY_MCP_SSH_PORT_MIN`: minimum allowed SSH port, default `1`
- `PTY_MCP_SSH_PORT_MAX`: maximum allowed SSH port, default `65535`
When `PTY_MCP_SSH_MANAGED_MOUNT_ROOT` is set, it is automatically added to the allowed cwd roots and mount roots.
## Example
Example with a tighter policy:
```toml
[mcp_servers.pty]
command = "pty-mcp"
[mcp_servers.pty.env]
PTY_MCP_ALLOWED_CWD_ROOTS = "/Users/alice/work:/tmp/pty-mcp"
PTY_MCP_ALLOWED_COMMANDS = "bash,sh,python,node,cargo"
PTY_MCP_SSH_ALLOWED_HOSTS = "*.example.com,github.com"
PTY_MCP_SSH_ALLOWED_USERS = "alice"
PTY_MCP_SSH_MANAGED_MOUNT_ROOT = "/tmp/pty-mcp-mounts"
```
## Development
```bash
cargo build
```
## Q&A
### If this is already the era of skills, why still use MCP?
Because PTY management needs the lifecycle of background processes to stay bound to the agent session, so those processes do not get detached and turn into orphan processes.
In that setup, MCP is a natural fit. Its lifecycle is already aligned with the session lifecycle, and it can act as the parent process for all managed background processes. That makes it a particularly good foundation for persistent PTY workflows.
## Acknowledgements
Thanks to [shekohex/opencode-pty](https://github.com/shekohex/opencode-pty) for sharing a thoughtful open-source PTY management implementation and for providing useful prior art while shaping `pty-mcp`.
## License
MIT