# pty-mcp

A lightweight PTY runner for MCP (Model Context Protocol).

`pty-mcp` provides terminal session tools over MCP so clients can spawn commands in a pseudo-terminal, read interactive output, send input, resize terminal windows, and manage process lifecycle safely.

## Features

- Spawn shell commands in a real PTY
- Read incremental output from a ring buffer
- Wait for pattern matching (`read_until`)
- Wait for any pattern (`read_until_any`)
- Send interactive input (`write`)
- Send input and wait for prompt (`prompt`)
- Resize terminal (`cols` / `rows`)
- Process control (`status`, `wait`, `signal`, `close`)
- Stream split reads (`read_stdout`, `read_stderr`)
- Read with offset (`read_at`)
- Session tags (`tag`, `owner`)
- Dynamic defaults (`set_default_cwd`)
- Runtime limits (`set_limits`)
- Metrics and health (`metrics`, `health`)

## Requirements

- Python 3.10+
- `tmux` 3.x when using `backend="tmux"`

## Installation

```bash
pip install pty-mcp
```

## CLI Entrypoint

After installation, the package exposes:

```bash
pty-mcp
```

This starts the MCP server over stdio.

## MCP Tools

By default, the server exposes the core session tools:

- `pty_spawn`
- `pty_resume`
- `pty_recover`
- `pty_attach`
- `pty_detach`
- `pty_handoff`
- `pty_takeover`
- `pty_read`
- `pty_read_stdout`
- `pty_read_stderr`
- `pty_read_at`
- `pty_read_until`
- `pty_read_until_any`
- `pty_read_quiescent`
- `pty_prompt`
- `confirm_dangerous_command`
- `pty_write`
- `pty_resize`
- `pty_close`
- `pty_status`
- `pty_wait`
- `pty_list`

Optional tools are available when explicitly enabled by runtime policy:

- control tool: `pty_signal` (`PTY_MCP_ENABLE_CONTROL_TOOLS=1`)
- admin/global tools: `pty_close_all`, `set_default_cwd`, `get_default_cwd`, `set_limits`, `get_limits`, `pty_metrics`, `pty_health` (`PTY_MCP_ENABLE_ADMIN_TOOLS=1`)

## Default Security Policy

Current package defaults are secure-by-default:

- `owner` is required by default for `pty_spawn`, `pty_list`, and follow-up session-bound calls
- dangerous commands require `confirm_dangerous_command` first, then `dangerous_confirm_token` in `pty_spawn`
- `pty_signal` and admin/global tools are hidden by default unless enabled explicitly via runtime environment variables
- when a session is attached to a logical `client_id`, destructive interactive tools require the same `client_id`

Attachment is coordination state, not an authorization override:

- `owner` and `scope` checks still run first
- `pty_attach` / `pty_detach` do not grant access across owners
- unattached sessions preserve the legacy single-client behavior

Compatibility escape hatches still exist, but they are opt-in:

- `PTY_MCP_REQUIRE_OWNER=0` restores owner-optional behavior
- `PTY_MCP_DANGEROUS_LEGACY_MODE=1` restores legacy dangerous-command confirmation via `dangerous_confirmed` / `dangerous_justification`

## New Parameters

### `pty_spawn`

- `cwd` — working directory
- `separate_streams` — split stdout/stderr into separate buffers
- `tag`, `owner` — session labels for filtering
- `name` — stable session name for later lookup/recovery
- `scope` — `read-only` or `read-write`
- `max_output_bytes` — ring buffer cap
- `spawn_timeout_sec` — session runtime cap
- `rate_limit_bps` — output rate limit
- `input_rate_bps` — input rate limit
- `read_timeout_ms` — default read timeout per session
- `env` — per-session environment overrides
- `backend` — execution backend (default: subprocess)
- `backend` currently supports `subprocess` and a minimal `tmux` backend.
- `backend="tmux"` maps one MCP session to one tmux session/pane; it does not expose tmux window or pane management as MCP tools.
- `tmux_workspace` — optional logical workspace name for `backend="tmux"`; sessions with the same `owner` + `tmux_workspace` reuse one internal tmux session while each MCP session keeps its own pane
- `dangerous_confirm_token` — token returned by `confirm_dangerous_command` for dangerous command execution
- `dangerous_confirmed` — legacy dangerous-command confirmation flag (compatibility mode only)
- `dangerous_justification` — legacy justification text (compatibility mode only)

### `backend="tmux"` caveats

- `separate_streams=true` is not supported for tmux sessions.
- `pty_signal` is not supported for tmux sessions in this phase.
- `pty_read*` reads pane output captured from the bound tmux pane; this is designed for shell / REPL / installer style workloads rather than exact full-screen terminal replay.
- `tmux_workspace` is a high-level grouping hint, not a public tmux control surface. Each MCP session still owns exactly one pane and is still addressed by `session_id`.
- `pty_resize` on shared tmux workspaces is best-effort and may affect sibling pane layout.
- `pty_resume` still resolves only in-memory live sessions tracked by `pty-mcp`; it does not rediscover orphaned tmux sessions after a server restart.
- `pty_recover` is the explicit/manual restart-recovery path for persisted tmux-backed named sessions; it is separate from `pty_resume`.
- `pty_attach` / `pty_detach` are logical MCP session lease operations; they do not map to native tmux attach/detach and do not expose pane/window/session management.

### `confirm_dangerous_command`

- returns `confirm_token` and `expires_at` for dangerous commands
- supports binding confirmation to `owner` and `scope`
- the returned token is single-use and TTL-bound

### `pty_resume`

- resolves an existing named session and returns its current status
- accepts `name` and optional `owner`
- under the default policy, use the same `owner` value that was used when spawning the session
- resolves in-memory live sessions only; it does not restore sessions across server restarts
- reconnect flow for attached sessions is: `pty_resume` -> `pty_attach`

### `pty_recover`

- explicitly rebuilds a persisted tmux-backed named session after manager restart
- requires both `name` and `owner`
- only supports tmux sessions that were spawned with a non-empty `name` and `owner`
- `pty_resume` stays live-only; `pty_recover` is the manual recovery path
- shared `tmux_workspace` recovery is all-or-nothing for the whole persisted group
- recovered sessions come back detached (`attached_client_id` is not restored as an active lease)
- recovery preserves `last_lease_*` as audit history only; it does not restore unread buffers, destructive read cursors, dangerous confirmation tokens, or seamless output continuity

### `pty_attach` / `pty_detach` / `pty_handoff` / `pty_takeover`

- `pty_attach(session_id, owner, client_id)` claims a logical client lease for an existing live session
- `pty_detach(session_id, owner, client_id)` releases that lease without closing the process, pane, or tmux workspace
- `pty_handoff(session_id, owner, client_id, target_client_id, reason)` explicitly transfers a current lease to another client
- `pty_takeover(session_id, owner, client_id, from_client_id, reason)` explicitly takes a current lease from another client
- attach state is backend-neutral and applies to both `subprocess` and `tmux` sessions
- attaching is idempotent for the same `client_id`
- attaching from a different `client_id` fails while the session is still attached
- for shared `backend="tmux"` sessions with the same `owner` + `tmux_workspace`, all attached live sessions must share one `client_id`
- detaching never destroys the underlying session; normal teardown still happens through `pty_close`
- handoff and takeover are explicit lease-transfer operations only; they do not attach to arbitrary tmux targets or restore sessions across restarts
- for shared tmux workspaces, handoff/takeover transfer all currently attached live sessions in that workspace together so workspace arbitration remains coherent

### Client lease semantics

- destructive interactive tools require the matching `client_id` only when a session is attached:
  - `pty_read`
  - `pty_read_stdout`
  - `pty_read_stderr`
  - `pty_read_until`
  - `pty_read_until_any`
  - `pty_read_quiescent`
  - `pty_prompt`
  - `pty_write`
  - `pty_resize`
  - `pty_close`
  - `pty_signal`
- observational tools stay usable without attachment:
  - `pty_read_at`
  - `pty_status`
  - `pty_wait`
  - `pty_list`
  - `pty_resume`
- this phase does not add per-client read cursors; use `pty_read_at` for observation without consuming shared buffers
- `pty_status` and `pty_list` expose workspace-level attachment state for shared tmux workspaces so callers can distinguish a session lease from a workspace lease
- `pty_status`, `pty_list`, and spawn metadata also expose the latest lease audit fields (`last_lease_*`) for explicit handoff/takeover tracing
- `pty_status` and `pty_list` also expose `recovered` / `recovered_at` for explicitly recovered sessions

### Session Logging

Set `PTY_MCP_SESSION_LOG_DIR` to enable per-session logs:

- `transcript.log` — raw output stream
- `events.jsonl` — JSON lines for spawn, write, attach, detach, handoff, takeover, and recover events

### Dangerous Command Rules

Set `PTY_MCP_DANGEROUS_PATTERNS` (semicolon-separated regex) to customize detection.

### `pty_read_quiescent`

- `quiescence_ms` — silence window before return

### `pty_read` / `pty_read_stdout` / `pty_read_stderr`

- `format` — `json` or `text` (default)

## Examples

### Set default cwd

```json
{"name":"set_default_cwd","arguments":{"cwd":"/root/aa"}}
```

### Spawn with stream split

```json
{"name":"pty_spawn","arguments":{"command":"ls","name":"demo-shell","owner":"demo-client","scope":"read-write","separate_streams":true}}
```

### Spawn with tmux backend

```json
{"name":"pty_spawn","arguments":{"command":"printf tmux-ok","name":"tmux-demo","owner":"demo-client","scope":"read-write","backend":"tmux"}}
```

### Spawn two panes in one tmux workspace

```json
{"name":"pty_spawn","arguments":{"command":"read line; printf \"pane-a:%s\" \"$line\"","name":"pane-a","owner":"demo-client","scope":"read-write","backend":"tmux","tmux_workspace":"demo-workspace"}}
```

```json
{"name":"pty_spawn","arguments":{"command":"read line; printf \"pane-b:%s\" \"$line\"","name":"pane-b","owner":"demo-client","scope":"read-write","backend":"tmux","tmux_workspace":"demo-workspace"}}
```

### Resume a named session

```json
{"name":"pty_resume","arguments":{"name":"demo-shell","owner":"demo-client"}}
```

### Recover a tmux-backed named session after restart

```json
{"name":"pty_recover","arguments":{"name":"tmux-demo","owner":"demo-client"}}
```

### Attach a client lease

```json
{"name":"pty_attach","arguments":{"session_id":"...","owner":"demo-client","client_id":"controller-a"}}
```

### Interactive read/write with a client lease

```json
{"name":"pty_write","arguments":{"session_id":"...","owner":"demo-client","client_id":"controller-a","data":"hello\n"}}
```

```json
{"name":"pty_read_quiescent","arguments":{"session_id":"...","owner":"demo-client","client_id":"controller-a","quiescence_ms":300}}
```

### Detach a client lease

```json
{"name":"pty_detach","arguments":{"session_id":"...","owner":"demo-client","client_id":"controller-a"}}
```

### Handoff a client lease

```json
{"name":"pty_handoff","arguments":{"session_id":"...","owner":"demo-client","client_id":"controller-a","target_client_id":"controller-b","reason":"operator handoff"}}
```

### Take over a client lease

```json
{"name":"pty_takeover","arguments":{"session_id":"...","owner":"demo-client","client_id":"controller-b","from_client_id":"controller-a","reason":"controller-a disconnected"}}
```

### Read stdout only

```json
{"name":"pty_read_stdout","arguments":{"session_id":"...","owner":"demo-client"}}
```

### Wait for any prompt

```json
{"name":"pty_read_until_any","arguments":{"session_id":"...","owner":"demo-client","patterns":["OK","ERROR"]}}
```

### Read until quiet

```json
{"name":"pty_read_quiescent","arguments":{"session_id":"...","owner":"demo-client","quiescence_ms":300}}
```

### Prompt helper

```json
{"name":"pty_prompt","arguments":{"session_id":"...","owner":"demo-client","data":"y\n","patterns":["done","error"]}}
```

### Confirm dangerous command

```json
{"name":"confirm_dangerous_command","arguments":{"command":"rm -rf /tmp/demo","justification":"cleanup temp data","owner":"release-bot","scope":"admin"}}
```

### Spawn a dangerous command with token

```json
{"name":"pty_spawn","arguments":{"command":"rm -rf /tmp/demo","owner":"release-bot","scope":"admin","dangerous_confirm_token":"dc_xxx"}}
```

## Notes

- PTY sessions are in-memory and have idle timeout cleanup.
- The server is intended for local trusted environments.
- `separate_streams=true` makes stdout/stderr non-tty (some commands may change output).
- `backend="tmux"` keeps the same MCP lifecycle (`spawn/read/write/status/wait/close`) but uses a dedicated tmux session under the hood.
- `backend="tmux"` with `tmux_workspace` reuses one internal tmux session for multiple MCP sessions, but it still does not expose tmux pane/window management as public MCP tools.
- `pty_attach` / `pty_detach` are in-memory logical lease state only; they are not persisted across server restarts.
- `pty_resume` stays name-based; phase-4 does not add attach-by-workspace or attach-by-tmux-target flows.
- `pty_recover` is explicit/manual recovery only. It is limited to tmux-backed named sessions with an owner and refuses ambiguous or partially invalid live tmux state.
- If `command` is an absolute path, cwd is inferred from its directory.
- `pty_signal` and admin/global tools are hidden by default; enable them explicitly with `PTY_MCP_ENABLE_CONTROL_TOOLS=1` and `PTY_MCP_ENABLE_ADMIN_TOOLS=1` when needed.
- Session names are checked within the same `owner` scope; if you run without `owner`, same-name sessions can become ambiguous or conflict globally.

## License

MIT
