{"id":50664361,"url":"https://github.com/tmytimidly/portal-mcp-server","last_synced_at":"2026-08-28T04:43:56.372Z","repository":{"id":360576618,"uuid":"1238666072","full_name":"TMYTiMidlY/portal-mcp-server","owner":"TMYTiMidlY","description":"Agent-first SSH orchestration MCP server: persistent bash sessions, hash-protected remote file editing, SFTP, SSH tunnels, and multi-host orchestration. Built on AsyncSSH + FastMCP with an in-process connection pool shared across every tool. Windows / macOS / Linux.","archived":false,"fork":false,"pushed_at":"2026-07-14T06:59:02.000Z","size":4493,"stargazers_count":3,"open_issues_count":2,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-14T08:28:08.200Z","etag":null,"topics":["agent","asyncssh","automation","coding-agent","fastmcp","mcp","model-context-protocol","python","sftp","ssh","ssh-tunnel"],"latest_commit_sha":null,"homepage":"https://pypi.org/project/portal-mcp-server/","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/TMYTiMidlY.png","metadata":{"files":{"readme":"README.en.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.en.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":"NOTICE","maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-14T10:34:30.000Z","updated_at":"2026-07-14T06:59:06.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/TMYTiMidlY/portal-mcp-server","commit_stats":null,"previous_names":["tmytimidly/portal-mcp-server"],"tags_count":13,"template":false,"template_full_name":null,"purl":"pkg:github/TMYTiMidlY/portal-mcp-server","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/TMYTiMidlY%2Fportal-mcp-server","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/TMYTiMidlY%2Fportal-mcp-server/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/TMYTiMidlY%2Fportal-mcp-server/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/TMYTiMidlY%2Fportal-mcp-server/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/TMYTiMidlY","download_url":"https://codeload.github.com/TMYTiMidlY/portal-mcp-server/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/TMYTiMidlY%2Fportal-mcp-server/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":36949459,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-08-22T15:14:58.755Z","status":"online","status_checked_at":"2026-08-28T02:00:06.244Z","response_time":114,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["agent","asyncssh","automation","coding-agent","fastmcp","mcp","model-context-protocol","python","sftp","ssh","ssh-tunnel"],"created_at":"2026-06-08T05:01:06.582Z","updated_at":"2026-08-28T04:43:56.362Z","avatar_url":"https://github.com/TMYTiMidlY.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n\n# portal-mcp-server\n\n**Agent-first SSH orchestration MCP server**\n\nLets coding agents (Claude Code, Copilot CLI, Cursor, …) drive remote machines as fluently as the local one: persistent bash sessions, hash-protected remote file editing, SFTP, SSH tunnels, multi-host orchestration. Built on [AsyncSSH](https://github.com/ronf/asyncssh) + [FastMCP](https://modelcontextprotocol.io/), with an in-process connection pool shared across every tool — identical reuse performance on Windows, macOS, and Linux.\n\n[![CI](https://github.com/TMYTiMidlY/portal-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/TMYTiMidlY/portal-mcp-server/actions/workflows/ci.yml)\n[![PyPI](https://img.shields.io/pypi/v/portal-mcp-server)](https://pypi.org/project/portal-mcp-server/)\n[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)\n[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)\n[![MCP](https://img.shields.io/badge/MCP-compatible-brightgreen)](https://modelcontextprotocol.io/)\n[![Last commit](https://img.shields.io/github/last-commit/TMYTiMidlY/portal-mcp-server)](https://github.com/TMYTiMidlY/portal-mcp-server/commits/main)\n[![Issues](https://img.shields.io/github/issues/TMYTiMidlY/portal-mcp-server)](https://github.com/TMYTiMidlY/portal-mcp-server/issues)\n\n[简体中文](./README.md) ｜ English\n\n\u003c/div\u003e\n\n\u003e ℹ️ The Chinese [`README.md`](./README.md) is canonical; this file is kept in\n\u003e lockstep with it.\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e📖 Table of contents\u003c/summary\u003e\n\n- [Overview](#overview)\n- [Highlights](#highlights)\n- [Architecture \u0026 design](#architecture-design)\n- [Install](#install)\n- [Client integration](#client-integration)\n- [Tools](#tools)\n- [Environment variables](#env-vars)\n- [Authentication](#authentication)\n- [Security](#security)\n- [Testing](#testing)\n- [CI / Release](#ci-release)\n- [FAQ](#faq)\n- [Contributing](#contributing)\n- [License \u0026 credits](#license-credits)\n\n\u003c/details\u003e\n\n## \u003ca id=\"overview\"\u003e\u003c/a\u003eOverview\n\nportal-mcp-server is built around three ideas: **few, orthogonal tools** (keep\nonly the guarantees bash can't cheaply synthesize), **step-wise \u0026 interruptible**\n(the agent calls one step at a time, reads real output, then decides; long tasks\ngo to the background), and **credential unification** (every connection goes\nthrough one in-process auth path; plaintext never enters the LLM / argv / disk).\n\n`portal-mcp-server` is forked from\n[`jaguar999paw-droid/ssh-shell-mcp`](https://github.com/jaguar999paw-droid/ssh-shell-mcp)\n(Apache 2.0): the underlying SSH/asyncssh engine, connection pool, tunnel\nmanagement, multi-host orchestration and security policy come from upstream. The\nupper layer is a redesigned agent-first tool surface built around three\nexecution paths — persistent bash sessions, one-shot exec, and background jobs —\nplus hash-protected remote editing, structured search, SFTP transfer, tunnels\nand audit. The double-hash safe-edit algorithm behind `remote_read` /\n`remote_patch` is adapted from [`tumf/mcp-text-editor`](https://github.com/tumf/mcp-text-editor)\n(MIT) and rewritten for SFTP.\n\nFull derivation and third-party algorithm provenance are in [`NOTICE`](./NOTICE)\nand the [Security](#security) section.\n\n## \u003ca id=\"highlights\"\u003e\u003c/a\u003eHighlights\n\n- **Cross-tool connection reuse**: all portal tools share one in-process asyncssh\n  connection pool; one handshake is reused for hours, and each call amortizes to\n  channel creation (~10–30 ms).\n- **Fast on Windows too**: no dependence on OpenSSH `ControlMaster`; the pool is\n  plain Python objects, so all three platforms get the same reuse performance.\n- **Persistent shell sessions**: `remote_shell` keeps one interactive shell\n  (bash/zsh) per host — cwd / env persist across calls, and `commands=[…]` runs\n  multiple steps in the same session; the agent needn't rebuild context per\n  command.\n- **Hash-protected remote edits**: `remote_read` + `remote_patch` use whole-file\n  SHA-256 + per-range hashes, write via tmp + `posix_rename` (atomic), and\n  re-hash after the write — **detecting** concurrent overwrites / mid-write\n  disconnects / line-number drift (optimistic checking that narrows the conflict\n  window; not a filesystem-level CAS).\n- **Agent-first minimal tool surface**: `action` / `mode` fields merge\n  semantically-overlapping entry points; each tool offers exactly one guarantee\n  bash can't cheaply synthesize, reducing tool-choice ambiguity. The tool schemas\n  (name + description + inputSchema) total about **~9k tokens** (≈ **4–5%** of a\n  200k context window; `tiktoken o200k_base` measures ~8.8k).\n- **Built-in security policy**: host allowlist, command blocklist/allowlist\n  (fnmatch), per-host rate limit, an audit log for every state-changing op,\n  fail-closed by default; optional [cc-safety-net](https://github.com/kenryu42/cc-safety-net)\n  semantic command gate (opt-in, unwraps `bash -c` / interpreter one-liners,\n  catches destructive git/rm, covering the `remote_exec`/`local_exec`/`shell`/`job`\n  paths that bypass the agent's own `bash` PreToolUse hook; fail-closed).\n- **OpenSSH config compatibility**: `~/.ssh/config` aliases, `known_hosts`,\n  ssh-agent are recognized automatically — no need to re-register hosts.\n- **One-command install**: `uv tool install portal-mcp-server` gives you both the\n  MCP server and the `portal` CLI; or zero-install via `uvx portal-mcp-server@latest`\n  straight from PyPI — no clone, no venv.\n\n## \u003ca id=\"architecture-design\"\u003e\u003c/a\u003eArchitecture \u0026 design\n\nportal-mcp-server is designed around three ideas: **few, orthogonal tools** (keep\nonly the guarantees bash can't cheaply synthesize), **step-wise \u0026 interruptible**\n(one call = one decidable step; read real output, then decide; long tasks go to\nthe background), and **credential unification** (every connection goes through one\nin-process auth path; plaintext never enters the LLM / argv / disk). Below: first\n\"how it differs from plain ssh\" and the data flow, then the trade-offs behind\nthose three ideas — everything but the three-idea intro is collapsed by default.\n\n### \u003ca id=\"vs-traditional\"\u003e\u003c/a\u003eVersus plain ssh / scp\n\nThe naive approach is to let the agent `bash` its way through `ssh` / `scp` /\n`rsync`. That is barely usable on Linux/macOS with `ControlMaster`, nearly\nunusable on Windows, and lacks key capabilities for file editing, sudo,\nmulti-host and audit.\n\n\u003cdetails\u003e\u003csummary\u003eExpand the per-dimension comparison (incl. the Windows reuse gap)\u003c/summary\u003e\n\n| Dimension | Plain (bash + `ssh` / `scp` / `rsync`) | portal-mcp-server |\n|---|---|---|\n| **SSH reuse · Linux/macOS** | OpenSSH `ControlMaster auto` + Unix socket; default `ControlPersist 10m`, master drops after timeout | asyncssh **in-process pool**, reused as long as the MCP server lives (hours) |\n| **SSH reuse · Windows** | ❌ **broken** — Microsoft's Win32-OpenSSH has had failing `ControlMaster` since v0.0.3.0 (`muxclient socket(): Unknown error`); [issue #405](https://github.com/PowerShell/Win32-OpenSSH/issues/405) open since 2017 (relies on Unix-domain-socket fd sharing, which Windows lacks) | ✅ **same performance as Linux** — the pool is a plain Python dict; asyncssh needs no OS-level socket sharing |\n| **First / subsequent command latency** | first ~200–500 ms; **without reuse every command is a new TCP+auth ~300 ms** (Windows default); ~10–30 ms subsequently with ControlMaster | first ~200–500 ms, **~10–30 ms subsequently (all three platforms)** — only a channel opens |\n| **Cross-\"tool\" reuse** | `ssh` and `scp` reuse requires identical `ControlPath` on both sides; in practice most projects don't share the master | ✅ all portal tools (bash / read / patch / transfer / tunnel …) naturally share one TCP |\n| **Persistent shell state** | each `ssh host cmd` is a fresh shell; `cd` / `export` / venv activation **all lost**; the agent must repeat `cd /path \u0026\u0026 source venv/bin/activate \u0026\u0026 …` every command | ✅ `remote_shell` keeps a sticky interactive shell (bash/zsh); cwd / env / venv persist across calls |\n| **Remote file editing (safe edit)** | all three are unsafe: ① `scp` down→edit→`scp` up (no concurrency detection, silent loss; non-atomic); ② `ssh host \"sed -i …\"` (no dry-run/rollback, error-prone line numbers); ③ `ssh host \"cat \u003e file\"` (concurrent overwrite, half-file on disconnect) | ✅ `remote_read` returns SHA-256 + range hashes; `remote_patch` verifies → writes `*.mcp_tmp.*` → `posix_rename` (atomic) → re-hash. **Concurrent edit / mid-write disconnect / line drift all fail instead of corrupting** |\n| **File / directory transfer** | `scp` has no incrementals, one failure sinks the batch; `rsync` is better but forks per run, **can't report progress to the agent**, and a large transfer can hit the MCP client's idle timeout | ✅ `remote_transfer` incremental short-circuit (size+mtime or sha256), **MCP progress heartbeat against idle timeout**, per-file failure goes to `failed[]` without aborting, `paths_json` batches arbitrary local↔remote pairs |\n| **sudo password ergonomics** | all footguns: ① `ssh -t host sudo cmd` **prompts every time**; ② `echo $PASS \\| ssh host \"sudo -S cmd\"` — **password enters the LLM context**; ③ `sshpass -p $PASS ssh …` — **password in `ps` argv and the LLM**; ④ NOPASSWD sudoers — auth abandoned | ✅ `remote_exec(use_sudo=True)`: source = ① `sudo_password_command` (pulled fresh from `pass` / `op` / `bw`, fully automatic) or ② `portal sudo set \u003chost\u003e` (a one-time no-echo `getpass` in another terminal → per-user credential-agent memory TTL). **Never in the LLM / ps argv / disk** |\n| **Multi-host parallelism** | `for h in $hosts; do ssh $h cmd; done` — **serial** startup (fork+auth each), no policy gate, one failure handled by `set -e` or the script | ✅ `remote_exec(host=[…])` true parallelism + two-phase gate (check all hosts, then execute), `serialize=True`+`delay_s` for rolling, `commands=[…]` for a sequence |\n| **SSH tunnel lifecycle** | `ssh -L 8080:db:5432 host -fN` runs away in the background — **nobody tracks when it closes**, who opened it, or if it's alive; you `pgrep` for it | ✅ `remote_tunnel(action=open)` returns a `tunnel_id`, `action=list` shows all live tunnels, `action=close` closes explicitly; audit-traceable |\n| **Command audit** | none — you'd wrap it yourself with `script(1)` / a shell-history wrapper; agent calls are invisible | ✅ state-changing tools pass the policy gate `_gate` first (denied = not run, no trace), then write structured `audit.jsonl` (host, operation, command, result, timestamp); a failed audit write is fail-closed by default (abort), relax with `PORTAL_AUDIT_FAIL_OPEN=1` |\n| **Structured search** | `ssh host \"grep -rn … \\| head\"` returns **raw text the agent parses**; degrades if rg is absent | ✅ `remote_grep` / `remote_glob` prefer `rg --json`, auto-fallback to `grep -rn` / `find`; return `{file, line, text}` structured |\n\n\u003e **Windows users take note**: the \"SSH reuse · Windows\" row is not a detail, it's\n\u003e a **fundamental gap**. The default Windows OpenSSH client has no ControlMaster,\n\u003e so the agent pays ~300 ms TCP+auth per remote command; 50 commands = 15 s of\n\u003e pure overhead. On Windows portal-mcp-server is ~280 ms first, ~20 ms after —\n\u003e identical to Linux — which is why we recommend it over the `ssh` subprocess\n\u003e approach.\n\n\u003c/details\u003e\n\n### \u003ca id=\"architecture\"\u003e\u003c/a\u003eArchitecture\n\nThe MCP client connects to the server over stdio (or optional HTTP); the 14 tools\npass the security gate + audit first, then SSH tools go through the in-process\nasyncssh connection pool (reusing one TCP across tools, multiple per host);\n`local_exec` / control-plane tools don't use SSH.\n\n\u003cdetails\u003e\u003csummary\u003eExpand the data-flow diagram\u003c/summary\u003e\n\n```\n┌──────────────┐    stdio / http    ┌─────────────────────────────────────┐\n│  MCP Client  │ ◄────────────────► │       portal-mcp-server             │\n│ (Claude Code │                    │                                     │\n│  Copilot CLI │                    │  ┌──────────┐   ┌────────────────┐  │\n│  Cursor ...) │                    │  │ 14 tools │──►│ security gate  │  │\n└──────────────┘                    │  └──────────┘   │ + audit log    │  │\n                                    │                  └───────┬────────┘  │\n                                    │                          │           │\n                                    │              ┌───────────▼────────┐  │\n                                    │              │  asyncssh pool      │  │\n                                    │              │  (in-process, one   │  │\n                                    │              │   TCP across tools) │  │\n                                    │              └──┬──────┬──────┬──┘  │\n                                    └─────────────────┼──────┼──────┼─────┘\n                                                      │      │      │\n                                               SSH    │      │      │\n                                              ┌───────▼─┐ ┌──▼──┐ ┌─▼──────┐\n                                              │ Host A  │ │ ... │ │ Host N │\n                                              └─────────┘ └─────┘ └────────┘\n```\n\n\u003c/details\u003e\n\n#### \u003ca id=\"cli-vs-mcp\"\u003e\u003c/a\u003eCLI vs. MCP server\n\nTwo invocation modes of the **same package / one binary**: launched with no\nsubcommand it is the **MCP server** (the agent runs remote tools through it);\nlaunched as `portal {ssh,passphrase,sudo,secret,agent} …` it is the **ops CLI**\n(a human, in another terminal). The two **never talk directly** — they coordinate\nonly through three shared channels:\n\n- **Credential-agent socket** — the CLI's `set` writes a no-echo credential into\n  the per-user credential agent; the MCP server reads it on demand at connect\n  time (see [credential agent](#credential-agent) below). The protocol has no\n  version handshake and admits peers by uid, so it is loosely coupled across the\n  shared credential kinds.\n- **Config files** — `hosts.yaml` / `policies.yaml` / `secrets.yaml` are read\n  **independently by each side**; this is the behaviour surface, so a new field\n  or semantic only agrees once both ends are on a version that understands it.\n- **`agent.json`** — records the credential agent's socket path so the CLI and\n  the MCP server point at the same agent.\n\nSo the CLI and the server can upgrade independently, even run briefly at\ndifferent versions (credentials still interoperate); only new config-file\nsemantics need both ends updated to agree.\n\n### \u003ca id=\"design-principles\"\u003e\u003c/a\u003eDesign principles\n\nThe single criterion: **keep a tool only when it provides a guarantee bash can't\ncheaply synthesize**. Each principle below is collapsed; the heading is the point.\n\n### Few, orthogonal tools\n\u003cdetails\u003e\u003csummary\u003eExpand\u003c/summary\u003e\n\nAnthropic's [_Writing Tools for Agents_](https://www.anthropic.com/engineering/writing-tools-for-agents)\nsays plainly: \"More tools don't always lead to better outcomes… Tools that\nmerely wrap existing software functionality is a common error… Too many tools or\noverlapping tools can also distract agents from pursuing efficient strategies.\"\n\nAccordingly the surface is a small, orthogonal set of primitives. Anything a\none-line bash could do, or that overlaps another tool, is not its own tool —\nit's covered by `remote_shell` (persistent bash session) + `remote_exec`\n(one-shot, incl. multi-host fanout / sudo / secrets). Each surviving tool holds\none such guarantee: `remote_read`+`remote_patch` (double hash vs. bare\n`cat`/`sed`/`\u003e`), `remote_grep`/`remote_glob` (structured output, `rg --json`\nfirst, fallback `grep`/`find`), `remote_shell`/`remote_exec` (persistent shell +\nexit code; true parallel fanout + two-phase gate + no credential leak),\n`remote_transfer` (incremental short-circuit + progress heartbeat + per-file\ntolerance), `remote_job` (background submit/poll/cancel/list), and\n`remote_tunnel`/`hosts`/`inspect` (merge multiple actions of one resource into an\n`action`/`view` field). All dispatch params are `typing.Literal` (schema-level\n`enum`), so the agent needn't choose among overlapping tools. Tool schemas total\nabout **~9k tokens** (`tiktoken o200k_base` ~8.8k, ~4–5% of a 200k window).\n\n\u003c/details\u003e\n\n### \u003ca id=\"step-wise-exec\"\u003e\u003c/a\u003eStep-wise \u0026 interruptible execution\n\u003cdetails\u003e\u003csummary\u003eExpand\u003c/summary\u003e\n\n`remote_exec` / `remote_shell` are **single-step** primitives: one call = one\ndecidable step. Read the *real* stdout / stderr / exit code, reconcile with\nexpectations (an exit-0 step can still be wrong), then decide the next call — so\nthe agent stays in the loop and can correct on error.\n\n- `commands=[…]` packs several commands into **one** call; the agent sees no\n  intermediate output, so it's only for fixed, dependency-free batches that need\n  no mid-inspection. Likewise don't bury a long branchy flow in one `a \u0026\u0026 b \u0026\u0026 c`.\n- Foreground `timeout` is **mandatory** (no default), forcing the agent to think\n  about \"how long should this take\" — small values (10–30 s) for exploratory /\n  re-runnable commands.\n- Foreground timeout is also capped by `PORTAL_MAX_TIMEOUT` (default 300 s); over\n  the cap is refused — **truly long unattended work goes to the background\n  `remote_job`** (instant submit, poll/cancel, survives disconnect).\n\n\u003c/details\u003e\n\n### \u003ca id=\"connection-pool\"\u003e\u003c/a\u003eIn-process connection pool\n\u003cdetails\u003e\u003csummary\u003eExpand\u003c/summary\u003e\n\nThe server keeps an asyncssh connection pool inside its own process — every tool\ncall shares one TCP. **All but the first connection amortize to channel creation\n(~10–30 ms).**\n\n- **Pool shape**: `PORTAL_SSH_POOL_SIZE` caps TCP connections per host (default\n  5), `PORTAL_SSH_MAX_CHANNELS_PER_CONN` caps channels per TCP (default 5); over\n  that opens a new TCP, and beyond the pool it reuses the least-busy connection\n  with a warning. asyncio supports true concurrency of many channels on one TCP.\n- **Idle \u0026 aging**: `PORTAL_SSH_MAX_IDLE_TIME` default 600 s, `PORTAL_SSH_MAX_CONN_AGE`\n  default 3600 s; idle/aged connections with no active channel are closed to\n  avoid silent NAT/firewall drops.\n- **Micro-benchmark (sanitized)**: same LAN (\u003c1 ms RTT), 100× `echo pong` — plain\n  ssh + ControlMaster ~23 ms avg; portal via `remote_shell` ~18 ms avg. First\n  connect ~280 ms both (auth dominates).\n- **On Windows**: plain ssh is ~300 ms × N (no reuse); portal is ~280 ms first,\n  ~20 ms after — asyncssh is pure Python, the pool lives in process memory, no\n  OS-level socket sharing (exactly where Windows OpenSSH ControlMaster fails).\n\n\u003c/details\u003e\n\n### Persistent shell sessions \u0026 command boundaries\n\u003cdetails\u003e\u003csummary\u003eExpand\u003c/summary\u003e\n\n`remote_shell` gives the agent one per-host, cross-command `bash -i` / `zsh -i` —\ncwd, env and shell functions persist automatically (same underlying process).\nThis is a second layer of reuse on top of the connection pool: the pool reuses\nTCP channels for **speed**, the persistent session reuses one interactive shell\nfor **state continuity**.\n\nThe hard part: one `bash -i` runs many commands on the **same** SSH channel, and\nSSH reports the exit code only when the channel **closes**. To get each command's\n`$?` without tearing down the channel (which would lose cwd/env), we mark command\nboundaries. The old approach was an in-band sentinel (append `echo \u003csentinel\u003e:$?`\nand scan stdout), which mixes control into the data stream and is fragile at the\nroot.\n\nThe current approach borrows **OSC 133 (FinalTerm) shell integration** (used by\niTerm2 / VS Code / Kitty / WezTerm): the **shell itself emits** command\nboundaries. On first use a small integration script is injected via stdin\n(**stdin only, never on disk**), hooking `PROMPT_COMMAND` / `precmd` to print\n`\\x1b]133;D;\u003cexit\u003e\\x07` after each command; we degrade to a **pure parser**. The\nsequence starts with an ESC byte, so ordinary text — **even literally\n`]133;D;0`** — can't forge it; `$?` is read straight from the marker, and the\nwhole class of sentinel fragility disappears.\n\nTwo capabilities come free: a command wedged on an interactive prompt (sudo / ssh\nfirst-connect / `mysql -p` / gpg passphrase) is **auto-Ctrl-C'd with the session\npreserved** (soft-cancel); a foreground timeout likewise Ctrl-C's and resyncs,\nkeeping the session if a clean prompt returns and dropping it otherwise. The\none-shot `remote_exec` path opens a fresh channel per command and reads the\nnative exit code from asyncssh, so it's immune to all of this.\n\n\u003e **Real-machine spikes** (recorded in `session_manager.py`): the shell must use\n\u003e `--noprofile --norc` / `--no-rcs`, or a user rc overwrites the hook; **zsh must\n\u003e `unsetopt zle`** (ZLE ignores `stty -echo` and leaks the command line); multi-line\n\u003e commands are wrapped in `{ … }` so an interactive shell fires one marker per\n\u003e top-level input line; fish is not verified and falls back to bash.\n\n\u003c/details\u003e\n\n### Choosing asyncssh over subprocess\n\u003cdetails\u003e\u003csummary\u003eExpand\u003c/summary\u003e\n\n[asyncssh](https://github.com/ronf/asyncssh) (EPL-2.0 / GPL-2.0 dual-licensed) is\nan independent pure-Python SSHv2 implementation, protocol-equivalent to OpenSSH.\nChoosing it over shelling out to `ssh`/`scp` is what makes the in-process pool,\ncross-tool channel reuse, the no-argv-password credential path, and identical\nWindows performance possible — a shelled-out subprocess shares none of them (see\n[Credential unification](#credential-unification)).\n\n\u003c/details\u003e\n\n### \u003ca id=\"credential-unification\"\u003e\u003c/a\u003eOne in-process auth path\n\u003cdetails\u003e\u003csummary\u003eExpand\u003c/summary\u003e\n\nEvery credential kind — SSH key, login password, key passphrase, sudo password,\nnamed secret — is resolved on one in-process asyncssh path and handed only to its\nreal consumer (the handshake, `sudo -S` stdin, an injected env var); plaintext\nnever reaches the agent conversation, argv/`ps`, or disk.\n\nThe trade-off: treat credential unification as an inviolable invariant —\n\"survive the agent stopping\" goes to `remote_job` (the command is `nohup`-ed on\nthe **remote** host, so the credential was already consumed at connect time and no\nlocal child holds it), and an interrupted foreground transfer recovers via\n`remote_transfer`'s `resume`, neither of which forks a credential-diverging\nsubprocess. Full rationale and rejected options in [ADR-0003](./docs/adr/0003-credential-unification.en.md).\n\n\u003c/details\u003e\n\n### Feedback channel: warnings ride the tool result\n\u003cdetails\u003e\u003csummary\u003eExpand\u003c/summary\u003e\n\nA stdio MCP server's stderr is invisible to the user, so operationally important\nwarnings (misconfigured yaml, missing credentials, ignored fields, host conflicts)\nare collected server-side and returned on `hosts(action=\"list\")` rather than only\nlogged. The agent is expected to relay them to the user.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\u003csummary\u003eMaintainer boundaries \u0026 footguns\u003c/summary\u003e\n\n- **exec-output-strip vs. file-read-must-not-strip**: one-shot exec strips\n  trailing newlines (shell convention), but `remote_read` must preserve bytes\n  exactly (the hash depends on it) — don't unify them.\n- **ssh_config merge internals**: to inherit an alias's long-tail options you must\n  connect with `host=\u003calias\u003e`, which pins `HostName`; see [ADR-0002](./docs/adr/0002-ssh-config-merge.en.md).\n- **sudo write preserves owner/mode**: the sudo patch path stats and restores\n  owner:group:mode; the staged plaintext copy is created `0600` and removed even\n  on failure.\n\n\u003c/details\u003e\n\n## \u003ca id=\"install\"\u003e\u003c/a\u003eInstall\n\nportal-mcp-server is installed like any other MCP server — register it with your\nMCP client (see [modelcontextprotocol.io](https://modelcontextprotocol.io/) for\nwhat MCP is). It runs on [`uv`](https://docs.astral.sh/uv/); if you don't have it,\ninstall it (`curl -LsSf https://astral.sh/uv/install.sh | sh`; Windows:\n[uv install docs](https://docs.astral.sh/uv/getting-started/installation/)).\n\n**Recommended: `uv tool install portal-mcp-server`** — one install puts both the\nMCP server binary and the `portal` short-command CLI on your PATH (`~/.local/bin`):\n\n```bash\nuv tool install portal-mcp-server     # installs portal-mcp-server + portal\nuv tool upgrade portal-mcp-server     # update later (or uv tool upgrade --all)\n```\n\nWhy persistent over `uvx`: the credential ops (`portal ssh/sudo/secret set`, …)\nare **everyday commands you type by hand**, so you want the short `portal …`; and\nthe MCP server and CLI are then the **same build at the same version** (no drift),\nwith no per-launch `@latest` network re-resolution. In your client set `command`\nto `portal-mcp-server` (see [Client integration](#client-integration)).\n\n\u003e **Zero-install / just trying it**: you can skip installing and let the client\n\u003e launch `uvx portal-mcp-server@latest` straight from PyPI (cached on first run,\n\u003e seconds after). The cost: every launch re-resolves `@latest` over the network\n\u003e and you don't get the `portal` short command — not worth it if you use the CLI a lot.\n\nFastest start (Claude Code shown; other clients under [Client integration](#client-integration)):\n\n```bash\n# 1. Install (get the portal-mcp-server + portal commands)\nuv tool install portal-mcp-server\n# 2. Register (--scope user applies to all repos)\nclaude mcp add --scope user portal -- portal-mcp-server\n# 3. Make sure the target host is in ~/.ssh/config or hosts.yaml\n# 4. In chat, say \"show the last 50 lines of /var/log/syslog on myhost\";\n#    the agent calls remote_exec(\"myhost\", \"tail -50 /var/log/syslog\", timeout=30)\n```\n\n### Terminal users (use the MCP server, don't touch source)\n\nAfter `uv tool install portal-mcp-server`, set your client's `command` to\n`portal-mcp-server` (see [Client integration](#client-integration)); or skip the\ninstall and use `uvx portal-mcp-server@latest`. Manual smoke test:\n\n```bash\nportal-mcp-server --help              # installed\nuvx portal-mcp-server@latest --help   # or zero-install\n```\n\n### Developers (change code / run tests)\n\n\u003cdetails\u003e\u003csummary\u003eExpand the dev setup\u003c/summary\u003e\n\n```bash\ngit clone git@github.com:TMYTiMidlY/portal-mcp-server.git\ncd portal-mcp-server\nuv sync --all-extras\nsource .venv/bin/activate\npytest                              # all green (live SSH tests skip by default)\nuv tool install --force --editable .   # run this checkout as the MCP server (edits apply live)\n```\n\n\u003c/details\u003e\n\n### The `portal` short command\n\n`portal` and `portal-mcp-server` are the same entry point. With no subcommand it\nstarts the MCP server; the credential-agent CLI lives under\n`portal {agent,ssh,passphrase,sudo,secret} …` (see [Authentication](#authentication)).\n\n### \u003ca id=\"credential-agent\"\u003e\u003c/a\u003eCredential agent (systemd / launchd / scheduled task)\n\n\u003cdetails\u003e\u003csummary\u003eExpand credential-agent install\u003c/summary\u003e\n\n`portal agent install` installs a per-user credential agent that holds\ninteractively-entered credentials in memory with a TTL. Auto-install covers\n**Linux + macOS + Windows**, always running **as the logged-in user** (never a\nsystem/root service): systemd user units (Linux, `.socket` + `.service`,\nsocket-activated), a launchd LaunchAgent (macOS), or a per-user logon scheduled\ntask (Windows, Task Scheduler with an InteractiveToken principal). Linux/macOS\nsupervise it on an AF_UNIX socket; Windows uses a named pipe. The installer\nrecords the resolved socket/pipe address in `~/.config/portal-mcp-server/agent.json`\nso clients read it directly (or an explicit `PORTAL_CREDENTIAL_AGENT_SOCKET`). A\nrunning MCP server discovers a freshly-installed agent on the next credential\nrequest (it re-reads `agent.json`); no restart needed. See [Authentication](#authentication).\n\n\u003c/details\u003e\n\n## \u003ca id=\"client-integration\"\u003e\u003c/a\u003eClient integration\n\n### Generic config snippet\n\n**Recommended** (after `uv tool install portal-mcp-server`, `command` is the bare\nbinary name):\n\n```json\n{\n  \"mcpServers\": {\n    \"portal\": {\n      \"command\": \"portal-mcp-server\",\n      \"args\": []\n    }\n  }\n}\n```\n\nZero-install (no install, uvx pulls on launch):\n\n```json\n{\n  \"mcpServers\": {\n    \"portal\": {\n      \"command\": \"uvx\",\n      \"args\": [\"portal-mcp-server@latest\"]\n    }\n  }\n}\n```\n\n\u003e If the host can't find `portal-mcp-server` (or `uvx`) — common with GUI apps\n\u003e (Claude Desktop / VS Code) that don't inherit your shell PATH — put the absolute\n\u003e path from `which portal-mcp-server` (Windows: `where portal-mcp-server`) in\n\u003e `command`. The `uv tool` path (`~/.local/bin/portal-mcp-server`) is stable across\n\u003e `uv tool upgrade`, so hardcoding it is safe.\n\nTo pass environment variables (pointing at custom hosts/policies/log paths),\nadd `env`:\n\n```json\n\"env\": {\n  \"PORTAL_HOSTS_YAML\": \"/path/to/hosts.yaml\",\n  \"PORTAL_POLICIES_YAML\": \"/path/to/policies.yaml\",\n  \"PORTAL_LOG_DIR\": \"/path/to/logs\"\n}\n```\n\n\u003e 💡 **`timeout` is now mandatory** (no default) — `remote_exec` / `remote_shell`\n\u003e / `local_exec` each require the agent to pass a seconds value per call. A\n\u003e keepalive heartbeat is sent during execution so the MCP client won't cut a\n\u003e hanging call, making `timeout` the only real cutoff. Foreground timeout is also\n\u003e capped by `PORTAL_MAX_TIMEOUT` (default 300 s); over the cap is refused with a\n\u003e hint to use the background `remote_job`.\n\n### Claude Code CLI\n\n```bash\n# Recommended: user scope, all repos (uv tool installed → command is portal-mcp-server)\nclaude mcp add --scope user portal -- portal-mcp-server\n# Without --scope it defaults to local (current dir only)\nclaude mcp add portal -- portal-mcp-server\n# Zero-install: replace portal-mcp-server with  uvx portal-mcp-server@latest\n# or type /mcp inside a Claude Code session\n```\n\n\u003e ⚠️ Claude Code has three scopes: `local` (**default**, current dir), `user`\n\u003e (all repos), `project` (written into the repo's `.mcp.json`). For \"install once,\n\u003e use everywhere\" **use `--scope user`** — unlike Codex (`mcp add` = global) or\n\u003e Copilot CLI (`mcp add` = User scope).\n\n\u003cdetails\u003e\u003csummary\u003e\u003cb\u003eGitHub Copilot CLI\u003c/b\u003e\u003c/summary\u003e\n\n```bash\ncopilot mcp add portal -- portal-mcp-server\n# Zero-install: replace portal-mcp-server with  uvx portal-mcp-server@latest\n# or /mcp inside a Copilot CLI session\n```\n\nVerify: `copilot mcp list` (should show portal) / `copilot mcp get portal`.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\u003csummary\u003e\u003cb\u003eCursor\u003c/b\u003e\u003c/summary\u003e\n\nWrite the generic snippet into `~/.cursor/mcp.json` (global) or\n`\u003cproject\u003e/.cursor/mcp.json` (per-project). Enable under Settings → Tools \u0026 MCP.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\u003csummary\u003e\u003cb\u003eVS Code (Copilot Chat / Agent mode)\u003c/b\u003e\u003c/summary\u003e\n\nVS Code uses a proprietary schema whose top-level key is `servers`, not\n`mcpServers`:\n\n```json\n{\n  \"servers\": {\n    \"portal\": {\n      \"type\": \"stdio\",\n      \"command\": \"portal-mcp-server\",\n      \"args\": []\n    }\n  }\n}\n```\n\nZero-install: use `\"command\": \"uvx\"` + `\"args\": [\"portal-mcp-server@latest\"]`.\nVS Code is a GUI app that may not inherit your shell PATH — if `portal-mcp-server`\nisn't found, use the absolute path from `which portal-mcp-server` (same as the\nPATH note under the generic snippet above). Write it to\n`\u003cproject\u003e/.vscode/mcp.json`, or the `mcp` field of user `settings.json` for global use.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\u003csummary\u003e\u003cb\u003eClaude Desktop\u003c/b\u003e\u003c/summary\u003e\n\nPaste the generic `mcpServers` snippet into `claude_desktop_config.json` and\nrestart. Location: macOS `~/Library/Application Support/Claude/…`; Windows\n`%APPDATA%\\Claude\\…`.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\u003csummary\u003e\u003cb\u003eWindsurf\u003c/b\u003e\u003c/summary\u003e\n\nSame `mcpServers` schema, written to `~/.codeium/windsurf/mcp_config.json` via\nCascade → plugins → \"Manually configure MCP\".\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\u003csummary\u003e\u003cb\u003eOpenAI Codex CLI\u003c/b\u003e\u003c/summary\u003e\n\n```bash\ncodex mcp add portal -- portal-mcp-server   # global\n# Zero-install: replace portal-mcp-server with  uvx portal-mcp-server@latest\n```\n\nOr edit `~/.codex/config.toml`:\n\n```toml\n[mcp_servers.portal]\ncommand = \"portal-mcp-server\"\nargs = []\n# Zero-install: command = \"uvx\", args = [\"portal-mcp-server@latest\"]\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\u003csummary\u003e\u003cb\u003eOther hosts (Cline / Continue / Roo Code / Zed …)\u003c/b\u003e\u003c/summary\u003e\n\nMost accept the generic `{ \"mcpServers\": ... }` snippet in their MCP settings;\nstdio needs no extra proxy.\n\n\u003c/details\u003e\n\n## \u003ca id=\"tools\"\u003e\u003c/a\u003eTools\n\n14 tools. Inclusion criterion: **keep only guarantees the agent can't synthesize\nitself** (concurrency, atomic/hash anti-conflict, no credential leak, security\ngate, real structured output); anything that just \"packages a script/state\" is\ncut or folded into a primitive.\n\n### Running commands: the exec family (by stateful / local / sync vs async)\n\n| Tool | When to use |\n|---|---|\n| `remote_exec` | **Default workhorse.** Stateless one-shot, immediate result (**separate** stdout/stderr + exit code). `host` single / list / `group_tag`; `command` or a `commands` sequence; multi-host parallel by default, `serialize=True`(+`delay_s`) for rolling; `use_sudo` / `secrets` inject credentials out-of-band. Reuses the pool; fast. |\n| `remote_shell` | Use only when **cwd/env must persist across calls** (`cd`/`export`/venv) — one sticky interactive shell (bash/zsh) per host, optional `commands=[…]` multi-step (state continues). Output is a **merged** stream (PTY). Otherwise use `remote_exec` (faster, multi-host). |\n| `remote_job` | **Background** long tasks. `submit` returns a `job_id` instantly (remote `nohup`+tmp, **survives disconnect**), `poll` fetches incremental output/status, `cancel` kills, `list` lists. Job table in-memory, capped, TTL-swept; sudo/secrets **not** supported in the background (use `remote_exec`). |\n| `local_exec` | Runs on the **MCP server's own machine** (not over SSH) — off-target for a remote-orchestration project, so **off by default**; the operator must set `PORTAL_ALLOW_LOCAL_EXEC=1`. `use_sudo=True` uses local `sudo -S -k` (reserved identity `\u003clocal\u003e`, password from `portal sudo set-local` or a top-level `\u003clocal\u003e:` `sudo_password_command`), can combine with `secrets`. |\n| `remote_close` | Closes a host's sticky `remote_shell` session (next `remote_shell` reopens). Rare; only to reset a dirty session. |\n\n\u003e **★ Two layers of \"reuse\", don't conflate**: **connection reuse** = the asyncssh\n\u003e TCP/channel pool, shared by **all** tools, purely for **speed**; **session reuse**\n\u003e = only `remote_shell`'s per-host sticky interactive shell, for **state\n\u003e continuity**. The shell session rides on a pooled channel; the two are\n\u003e orthogonal. Because the session is implicit plumbing, its state table lives in\n\u003e `inspect(view=\"sessions\")`, not a `list` of its own.\n\n### File editing / search / transfer\n\n| Tool | What it gives the agent |\n|---|---|\n| `remote_read` / `remote_patch` | Read a remote file and get SHA-256; patch uses `file_hash` + per-range hash against concurrent overwrite, writes via tmp + `posix_rename` (atomic), re-hashes after. **On success sweeps orphan `*.mcp_tmp.*` \u003e1h old in the same dir** (piggybacks the open SFTP session, fully isolated) — so there's no separate cleanup tool. |\n| `remote_grep` | Faithful port of Claude Code's Grep: `output_mode=files_with_matches` (default, paths mtime-desc) / `content` (matches + optional context, `head_limit` caps **total lines**, `offset` paginates) / `count`. Clear param names (`before_context`/`after_context`/`context`/`ignore_case`), respects `.gitignore`, each result carries `truncated`. **Don't run bare `rg` via `remote_exec`.** |\n| `remote_glob` | Faithful port of CC's Glob: `rg --files --no-ignore --sort modified -g`, **mtime-desc**, hard cap 100, `truncated`, returns `{filenames, num_files, truncated, duration_ms}`. Does not respect `.gitignore` (CC Glob default). **Don't run bare `find` via `remote_exec`.** |\n| `remote_transfer` | `direction=upload\\|download\\|sync\\|mirror\\|upload-list\\|download-list`. SFTP binary-safe; `sync` pushes a dir, `mirror` pulls a dir, `*-list` transfers arbitrary local↔remote pairs from `paths_json`, size+mtime incremental short-circuit by default (`checksum=True` for sha256); per-file failure to `failed[]`; MCP progress heartbeat against idle timeout. Directory modes skip local symlinks (no escaping the tree). |\n\n### Resources (agent manages explicitly, so `list` rides with the tool)\n\n| Tool | action / params | Purpose |\n|---|---|---|\n| `hosts` | `action=list\\|register\\|remove` | Host registry. `register` needs `name`+`host` — or just `name` (auto-registers a same-named `~/.ssh/config` alias overlay). `tags` feed `remote_exec`'s `group_tag`. `list` also enumerates ssh-config `Host` aliases and resolves real `HostName`/`User`/`Port`, each with a `source` field and possible per-host `warnings` (relay them). **No password parameter.** |\n| `remote_tunnel` | `action=open\\|close\\|list`, `kind=local\\|reverse\\|socks` | Single-entry SSH tunnels. `open` passes the host gate; binds loopback by default (off-box exposure needs `PORTAL_ALLOW_TUNNEL_EXPOSURE=1`). `close` by `tunnel_id` (gate on the source host). |\n\n### Introspection / policy\n\n| Tool | view / params | Purpose |\n|---|---|---|\n| `policy_check` | `host`, optional `command` | Security dry-run, no execution. Returns `ALLOWED` / `BLOCKED: \u003creason\u003e` (and longer strings such as \"ALLOWED by policy but host … is not registered\"). ⚠️ The default policy is **permissive** — `ALLOWED` only means \"no rule currently blocks it\". |\n| `inspect` | `view=snapshot\\|server\\|sessions\\|history\\|stats\\|policy` | Read-only introspection **hub**: server metadata + pool + bash sessions + audit stats + policy. **hosts/tunnels are not here** — they're resources, listed by `hosts(action=list)` / `remote_tunnel(action=list)`. The `sessions` view is plumbing diagnostics (host→session_id sticky table). |\n\n### Picking a tool: dedicated vs `remote_exec`/`remote_shell`\n\n`remote_exec` runs anything, but **prefer the dedicated tool** — each has either a\nsafety guarantee or structured output:\n\n| To do | Use this (**not** a bare command) | Why |\n|---|---|---|\n| Read / edit a remote file | `remote_read` → `remote_patch` | SHA-256 + per-range hash, atomic rename, post-write rehash |\n| Search content / find files | `remote_grep` / `remote_glob` | structured JSON + token guardrails |\n| Transfer / sync | `remote_transfer` | SFTP binary-safe + incremental + progress heartbeat |\n| Multi-host exec | `remote_exec(host=[...])` / `group_tag=` | parallel / rolling + two-phase gate |\n| Open a tunnel | `remote_tunnel` | managed lifecycle, listable |\n| Background a long task | `remote_job` | exposes state + hands back control |\n\n### \u003ca id=\"agent-conventions\"\u003e\u003c/a\u003eAgent-side conventions\n\n`portal-mcp-server` only provides tools; it doesn't mandate usage. Recommended\nadditions to `AGENTS.md` / the system prompt: default writes to remote `/tmp/`;\nask before touching `$HOME` or project source; don't mix portal tool calls with\nraw `ssh`/`scp` in one task; when a task needs a token, guide the user to\n`portal secret set` rather than asking for the plaintext.\n\n\u003cdetails\u003e\u003csummary\u003e📋 Full per-tool reference (signatures · returns · source map)\u003c/summary\u003e\n\n### Running commands: the exec family\n\n| Tool | Signature | Returns / key behavior |\n| --- | --- | --- |\n| `remote_exec` | `(host='' \\| [host…], command='', commands=None, group_tag='', *, timeout, login=None, use_sudo=False, secrets=None, serialize=False, delay_s=0.0, stop_on_error=True)` | Stateless one-shot over the pool. **single host + single command → one dict** (**separate** stdout/stderr + exit code); multi-host / `commands` sequence → **list** (a multi-command host is `{host, results:[…]}`). `timeout` **required** (no default; over `PORTAL_MAX_TIMEOUT` is refused and routed to `remote_job`); `login` defaults to a login shell (`bash -lc`). |\n| `remote_shell` | `(host, command='', commands=None, stop_on_error=True, *, timeout)` | One persistent interactive shell per host. single command → `{host, session_id, command, exit_code, output, duration_s}` (`output` is a merged PTY stream, over-limit truncation flags `truncated`); `commands=[…]` runs in the **same** session → `{host, session_id, results:[…], duration_s}`. A wedged interactive prompt is auto-Ctrl-C'd → `exit_code:-1` + `error:\"interactive_prompt_blocked\"` + `session_preserved:true`. A timeout Ctrl-C's the command and resyncs (session kept if a clean prompt returns, else dropped). `timeout` **required**. |\n| `remote_job` | `(action=submit\\|poll\\|cancel\\|list, host='', command='', job_id='', since=0, tail=0, max_bytes=65536, signal=TERM\\|KILL, login=None, use_sudo=False, secrets=None)` | `submit` returns a `job_id` (remote `nohup` + tmp, survives disconnect); `poll` paginates (`since=\u003coffset\u003e` returns new bytes, capped at `max_bytes` default 64 KiB, with `more`; or `tail=N` for the tail — `tail` is a snapshot and is not bounded by `max_bytes`), base64 chunk + boundary-safe UTF-8 decode; `cancel` signals the process group and re-probes (won't signal a terminal job); `list` lists all. Job table best-effort persisted per process, capped, TTL-swept (`PORTAL_JOB_*`). `use_sudo` / `secrets` **not supported in the background**. |\n| `local_exec` | `(command, secrets=None, use_sudo=False, *, timeout)` | Runs on the **MCP server's own machine** (**not** SSH), off by default (`PORTAL_ALLOW_LOCAL_EXEC=1`). `timeout` **required** (same `PORTAL_MAX_TIMEOUT` cap, no background to route to). `use_sudo=True` uses reserved identity **`\u003clocal\u003e`** (≠ an SSH host `local`/`localhost`) via local `sudo -S -k`; combinable with `secrets`, flagged `high_risk`. |\n| `remote_close` | `(host)` | Closes a host's cached `remote_shell` session (auto-reopens next time). Rare; reset a dirty session. |\n\n### File editing (hash-protected)\n\n| Tool | Signature | Returns / key behavior |\n| --- | --- | --- |\n| `remote_read` | `(host, path, start=1, end=None, limit=None, encoding='utf-8', use_sudo=False)` | → `{content, file_hash, range_hash, start, end, total_lines, truncated}`. Paginated: ≤ `limit` lines (default `PORTAL_READ_MAX_LINES=2000`) + `PORTAL_READ_MAX_BYTES` (default 16384); if truncated early, `truncated=true` and `next_start` gives the resume point (always returns at least one complete line even if it exceeds the byte cap). `use_sudo=True` reads root-only files via `sudo cat` (hash still valid), flagged `high_risk`. |\n| `remote_patch` | `(host, path, file_hash, patches_json, encoding='utf-8', auto_newline=False, use_sudo=False)` | Hash-guarded range patch: rejected if the file changed since `remote_read` (returns `current_file_hash`); patches applied bottom-to-top, overlaps rejected, via `*.mcp_tmp.\u003c12hex\u003e` + `posix_rename`, re-hashed after. On success sweeps stale orphan tmp in the same dir. `use_sudo=True` reads/writes root-owned files (staged copy created `0600`, cleaned up even on failure), flagged `high_risk`. `patches_json` = `[{\"start\":int,\"end\":int\\|null,\"contents\":str,\"range_hash\":str}, …]` (`end==start-1` is the pure-insert idiom; a negative `end` is clamped, not tail-sliced). |\n\n### Remote search (faithful Claude Code port)\n\n| Tool | Signature | Returns / key behavior |\n| --- | --- | --- |\n| `remote_grep` | `(host, pattern, path='.', glob='', file_type='', output_mode=files_with_matches\\|content\\|count, ignore_case=False, before_context=0, after_context=0, context=0, head_limit=250, offset=0, multiline=False)` | Regex content search (`rg`, fallback `grep`). Full CC-like guarantees (`.gitignore`, mtime-desc, structured) hold under `rg`; the `grep` fallback parses `-A/-B/-C` context rows (tagged `context:true`) but does not sort by mtime or honor `.gitignore`/`file_type`/`multiline`. |\n| `remote_glob` | `(host, pattern, path='.')` | Glob file search, `rg --files --no-ignore --sort modified -g`, **mtime-desc**, hard cap 100 + `truncated` → `{filenames, num_files, truncated, duration_ms}`. Does not respect `.gitignore` (matches CC Glob). |\n\n### File transfer (SFTP)\n\n| Tool | Signature | Returns / key behavior |\n| --- | --- | --- |\n| `remote_transfer` | `(direction=upload\\|download\\|sync\\|mirror\\|upload-list\\|download-list, host, local_path, remote_path, checksum=False, paths_json='', resume=True)` | Binary-safe SFTP. Single-file (`upload`/`download`) → `{status, direction, host, bytes, duration_s, …}`; incremental (`sync`/`mirror`/`*-list`) skips size+mtime matches (`checksum=True` → sha256) → `{status, uploaded\\|downloaded, skipped, failed[], bytes_total, bytes_transferred, duration_s}`, per-file failure to `failed[]`. **Upload resume** (`resume=True`): a smaller remote partial gets only its tail appended, then the whole file sha256-verified; if that can't be verified (no remote `sha256sum`) it re-uploads fresh (`restarted_unverifiable`). Directory modes skip local symlinks and refuse symlink destinations. `*-list` needs `paths_json` = `[{\"local\":…,\"remote\":…}, …]`. |\n\n### Resources (agent manages explicitly)\n\n| Tool | Signature | Returns / key behavior |\n| --- | --- | --- |\n| `remote_tunnel` | `(action=open\\|close\\|list, kind=local\\|reverse\\|socks, host='', tunnel_id='', local_port=0, local_bind='127.0.0.1', remote_host='', remote_port=0)` | `open` passes the `host` gate: `local` forwards `localhost:local_port → remote_host:remote_port`, `reverse` exposes `local_bind:local_port` as `host:remote_port`, `socks` is a SOCKS5 proxy. Binds loopback by default; a non-loopback `local_bind`, or exposing a reverse tunnel on all remote interfaces, requires `PORTAL_ALLOW_TUNNEL_EXPOSURE=1`. `close` by `tunnel_id` (gate on the source host); `list` lists all. |\n| `hosts` | `(action=list\\|register\\|remove, name='', host='', user='root', port=22, key_path='', tags='')` | Runtime host registry. `register` needs `name`+`host` — or just `name` (auto-overlays a same-named `~/.ssh/config` alias). `tags` (comma-separated) feed `group_tag`. `list` also enumerates ssh-config aliases (resolving real `HostName`/`User`/`Port`), each with a `source` field + possible per-host `warnings` — relay them. **No password parameter.** |\n\n### Introspection / policy\n\n| Tool | Signature | Returns / key behavior |\n| --- | --- | --- |\n| `policy_check` | `(host, command='')` | Security dry-run → `\"ALLOWED\"` / `\"BLOCKED: \u003creason\u003e\"` (and longer diagnostics). Default policy is **permissive**. |\n| `inspect` | `(view=snapshot\\|server\\|sessions\\|history\\|stats\\|policy, limit=50, host_filter='')` | Read-only introspection of server **plumbing** + history. **hosts / tunnels are not here** — resources, listed by `hosts` / `remote_tunnel`. |\n\n\u003e **Credential CLI (out-of-band, not an MCP tool)**: the agent never sees\n\u003e credential values. Passwords / passphrases / secrets are pre-staged by a human\n\u003e in another terminal via `portal {ssh,sudo,passphrase,secret} set`, held by a\n\u003e per-user agent; `show` / `list` return only a sha256[:16] fingerprint + TTL,\n\u003e `confirm` re-types and compares. See [Authentication](#authentication).\n\n### Source map\n\n| Module | Tools / responsibility |\n| --- | --- |\n| `cli.py` | all `@mcp.tool()` definitions, `_gate()`/`_gate_exec()`, `inspect` assembly, credential CLI |\n| `connection_manager.py` | asyncssh pool + host registry (**SSH tools only**; `local_exec` / control-plane tools don't use SSH) |\n| `shell_engine.py` | `remote_exec`'s one-shot `ssh_exec` path (dispatch also spans `cli.py` / `remote_bash.py`) |\n| `remote_bash.py` | `remote_shell` / `remote_close` + `remote_exec`'s sudo / secrets one-shot path |\n| `session_manager.py` | persistent interactive shell sessions (OSC 133, soft-cancel, timeout interrupt) |\n| `job_manager.py` | `remote_job` |\n| `local_exec.py` | `local_exec` |\n| `remote_text_editor.py` | `remote_read`, `remote_patch` (+ orphan tmp sweep) |\n| `remote_search.py` | `remote_grep`, `remote_glob` |\n| `file_ops.py` | `remote_transfer` |\n| `network_tools.py` | `remote_tunnel` |\n| `credential_agent.py` | per-user socket / named-pipe activated TTL cache for `portal {ssh,passphrase,sudo,secret} set` |\n| `ssh_creds.py` / `passphrase_creds.py` / `sudo_creds.py` / `secrets_store.py` | credential resolution + output redaction |\n| `_peer_creds.py` | same-user peer check (Linux `SO_PEERCRED` / Windows named-pipe SID) |\n| `security.py` | policy engine: host allowlist, command blocklist/allowlist, per-host rate limit, cc-safety-net |\n| `audit.py` | `audit_log()` write + history ring buffer (`inspect` assembly in `cli.py`) |\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\u003csummary\u003e🔀 Migrating from old tool names\u003c/summary\u003e\n\n\u003e **From v4: all tools drop the `portal_` prefix** — remote-acting tools take a\n\u003e `remote_` prefix (`remote_exec` / `remote_shell` / `remote_read` / `remote_patch`\n\u003e / `remote_grep` / `remote_glob` / `remote_transfer` / `remote_tunnel` /\n\u003e `remote_job` / `remote_close`), local execution is `local_exec`, control-plane\n\u003e tools are `portal_host→hosts` / `portal_check→policy_check` / `portal_audit→inspect`.\n\u003e Clients already namespace by config key (`portal-remote_exec`), so a `portal_`\n\u003e prefix is redundant stutter. The table also covers the older `portal_bash`-era\n\u003e migration:\n\n| Old | New |\n|---|---|\n| `portal_bash(host, cmd)` | `remote_shell(host, cmd)` (persistent) or `remote_exec(host, cmd)` (one-shot, faster) |\n| `portal_bash(..., use_sudo=True / secrets=[…])` | `remote_exec(..., use_sudo=True / secrets=[…])` |\n| `portal_bash_close` | `remote_close` |\n| `portal_multi_exec(mode=parallel, hosts_json=…)` | `remote_exec(host=[…])` |\n| `portal_multi_exec(mode=rolling, …)` | `remote_exec(host=[…], serialize=True, delay_s=N)` |\n| `portal_multi_exec(mode=broadcast, commands_json=…)` | `remote_exec(host=[…], commands=[…])` |\n| `portal_playbook(host=…/group_tag=…)` | `remote_exec(host=…/group_tag=…, commands=[…])` |\n| `portal_ping(hosts_json=…)` | `remote_exec(host=[…], command=\"echo pong\")` |\n| `portal_tunnel_open/_close/_list` | `remote_tunnel(action=open\\|close\\|list, kind=…)` |\n| `portal_cleanup_tmps` | removed — `remote_patch` sweeps same-directory orphan tmps on success |\n| `portal_bash_status` | `inspect(view=\"sessions\")` |\n| — | **new** `remote_job(action=submit\\|poll\\|cancel\\|list)` |\n\n\u003c/details\u003e\n\n## \u003ca id=\"env-vars\"\u003e\u003c/a\u003eEnvironment variables\n\nAll configuration is via environment variables, uniformly prefixed `PORTAL_*`.\nSet them in the MCP client's `env` field — they affect only the server\nsubprocess.\n\n### Overview\n\n| Category | Variable | One-liner |\n|---|---|---|\n| File paths | `PORTAL_HOSTS_YAML` | host registry YAML |\n| File paths | `PORTAL_POLICIES_YAML` | security policy YAML |\n| File paths | `PORTAL_SECRETS_YAML` | named-secret YAML (source for `secrets=` in `remote_exec` / `local_exec`) |\n| File paths | `PORTAL_SSH_CONFIG` | OpenSSH client config path (the `ssh -F` equivalent) |\n| File paths | `PORTAL_LOG_DIR` | audit + server log dir |\n| File paths | `PORTAL_CREDENTIAL_AGENT_SOCKET` | credential-agent socket / named-pipe address override (defaults to the installed `agent.json`) |\n| Security \u0026 auth | `PORTAL_AUDIT_FAIL_OPEN` | whether a failed audit write is fail-open |\n| Security \u0026 auth | `PORTAL_AUDIT_MAX_BYTES` | `audit.jsonl` rotation threshold (bytes, default 10 MiB) |\n| Security \u0026 auth | `PORTAL_AUDIT_BACKUPS` | rotated files kept `audit.jsonl.1..N` (default 5) |\n| Security \u0026 auth | `PORTAL_AUTH_TOKEN` | HTTP transport (`--transport streamable_http`) auth token; **required** for a non-loopback bind, not needed for stdio / loopback |\n| Security \u0026 auth | `PORTAL_ALLOW_TUNNEL_EXPOSURE` | allow `remote_tunnel` to bind non-loopback / expose a reverse tunnel on all remote interfaces (default off, loopback only) |\n| Local exec | `PORTAL_ALLOW_LOCAL_EXEC` | whether `local_exec` is enabled (default off; set `1`) |\n| Connection pool | `PORTAL_SSH_POOL_SIZE` | max TCP connections per host |\n| Connection pool | `PORTAL_SSH_MAX_CHANNELS_PER_CONN` | max concurrent channels per TCP |\n| Connection pool | `PORTAL_SSH_MAX_IDLE_TIME` | idle-close timeout (s) |\n| Connection pool | `PORTAL_SSH_MAX_CONN_AGE` | max connection lifetime (s) |\n| Background jobs | `PORTAL_JOB_PERSIST` | persist the `remote_job` table across restarts (default on; `0`/`false` off) |\n| Background jobs | `PORTAL_JOB_STATE_FILE` | job-table path (default **per-process** `\u003cstate\u003e/jobs/\u003cpid\u003e.json`, so multiple server processes don't clobber each other; set = one fixed file) |\n| Background jobs | `PORTAL_JOB_MAX_LIVE` | concurrent live-job cap (default 50) |\n| Background jobs | `PORTAL_JOB_TTL` | seconds a finished job stays before sweep + remote-tmp removal (default 3600) |\n| Reliability | `PORTAL_BASH_HEARTBEAT_INTERVAL` | keepalive heartbeat interval during foreground execution (s) |\n| Reliability | `PORTAL_MAX_TIMEOUT` | **cap** on the per-command foreground `timeout` of `remote_exec` / `remote_shell` / `local_exec` (s, default 300); `timeout` is required, over the cap is refused and routed to `remote_job` |\n| Exec env | `PORTAL_LOGIN_SHELL` | whether `remote_exec` / `remote_job` default to a login shell (`bash -lc`, loading `~/.profile`/`.bash_profile` PATH/env). Default on; `0`/`false`/`no`/`off` off. Per-call `login` / hosts.yaml `login_shell:` override |\n| Remote read | `PORTAL_READ_MAX_LINES` | max lines per `remote_read` page when `limit` is omitted (default 2000) |\n| Remote read | `PORTAL_READ_MAX_BYTES` | max bytes per page (default 16384) |\n| Shell session | `PORTAL_SHELL_MAX_OUTPUT` | `remote_shell` per-command in-memory output cap (bytes, default 8 MiB, over-limit truncation flags `truncated`) |\n| Shell session | `PORTAL_SHELL_BOOT_TIMEOUT` / `PORTAL_SHELL_BOOT_QUIET` | persistent-session bootstrap timeout / quiet window (s, default 10 / 0.6) |\n| Shell session | `PORTAL_SHELL_INTERACTIVE_GRACE` / `PORTAL_SHELL_SOFT_CANCEL_TIMEOUT` | interactive-prompt grace / soft-cancel wait for the OSC133 D (s, default 1 / 3) |\n| Testing (dev only) | `PORTAL_TEST_LIVE` | run the real-SSH integration tests |\n| Testing (dev only) | `PORTAL_TEST_HOST` / `PORTAL_TEST_PORT` / `PORTAL_TEST_USER` / `PORTAL_TEST_KEY_PATH` | live-test target |\n\n### \u003ca id=\"file-paths\"\u003e\u003c/a\u003eFile paths\n\n| Variable | Meaning | Default |\n|---|---|---|\n| `PORTAL_HOSTS_YAML` | host registry YAML | `~/.config/portal-mcp-server/hosts.yaml` |\n| `PORTAL_POLICIES_YAML` | security policy YAML | `~/.config/portal-mcp-server/policies.yaml` |\n| `PORTAL_SECRETS_YAML` | named-secret YAML | `~/.config/portal-mcp-server/secrets.yaml` |\n| `PORTAL_SSH_CONFIG` | OpenSSH client config path | `~/.ssh/config` |\n| `PORTAL_LOG_DIR` | audit + server log dir | platform state dir (Linux `~/.local/state/portal-mcp-server/log/`; macOS/Windows use the native state dir) |\n\n\u003e `PORTAL_SSH_CONFIG` is portal's `ssh -F`: OpenSSH reads **no** env var for the\n\u003e config path, only `-F`; portal is a long-lived daemon with no per-connection\n\u003e flag, so it uses this variable and **mirrors `-F` exactly**: an **absolute path**\n\u003e reads only that one file (also suppressing system `/etc/ssh/ssh_config`); the\n\u003e literal **`none`** (any case) reads no config file at all (`ssh -F none`); unset\n\u003e reads user `~/.ssh/config` + system `/etc/ssh/ssh_config` (Windows\n\u003e `%PROGRAMDATA%\\ssh\\ssh_config`) as fallback, user-level first. Parsing reuses\n\u003e asyncssh's config parser (`Include`, `~` expansion).\n\nPath resolution priority: **env var \u003e XDG dir** (`$XDG_CONFIG_HOME` /\n`$XDG_STATE_HOME`). The cwd is **not** consulted — portal is a user-level daemon,\nnot a project tool, so cwd-relative autoloading would let any working directory\nsilently hijack your real config.\n\nThe repo's [`examples/`](./examples/) dir is the schema template — all `*.yaml`\nthere are **read-only samples**, never autoloaded. On first use, copy them to the\nXDG dir and edit in your real values. **`~/.config/portal-mcp-server/hosts.yaml`\nholds real credentials — never commit it.**\n\n### Security \u0026 auth\n\n| Variable | Meaning | Default |\n|---|---|---|\n| `PORTAL_AUDIT_FAIL_OPEN` | `1` → a failed audit write only warns and continues; default → **fail-closed**, the op raises and aborts | _(unset)_ |\n| `PORTAL_ALLOW_LOCAL_EXEC` | set `1` to enable `local_exec` (off-target local execution, default off) | _(unset)_ |\n| `PORTAL_ALLOW_TUNNEL_EXPOSURE` | set `1` to let `remote_tunnel` bind non-loopback (`local_bind`) or expose a reverse tunnel on all remote interfaces; default loopback only | _(unset)_ |\n| `PORTAL_AUTH_TOKEN` | HTTP transport auth token (client sends `Authorization: Bearer \u003ctoken\u003e`). Transport **defaults to `--host 127.0.0.1`**; binding a non-loopback address without this value **refuses to start**. Not needed for stdio | _(none)_ |\n\n### Connection pool\n\n| Variable | Meaning | Default |\n|---|---|---|\n| `PORTAL_SSH_POOL_SIZE` | max TCP connections per host; when the pool is full and all are at the channel limit, the least-busy is reused (with a warning) | `5` |\n| `PORTAL_SSH_MAX_CHANNELS_PER_CONN` | max concurrent channels per TCP (SFTP/exec/tunnel share); over that opens a new TCP up to `PORTAL_SSH_POOL_SIZE` | `5` |\n| `PORTAL_SSH_MAX_IDLE_TIME` | close a channel-less connection after this idle time (s). **Note `0` is not \"disable\"** — it makes any idle connection immediately reclaimable | `600` (10 min) |\n| `PORTAL_SSH_MAX_CONN_AGE` | max connection lifetime (s); closed when aged and channel-less. Guards against firewall/NAT silent drops | `3600` (1 h) |\n\n### Reliability \u0026 execution\n\n| Variable | Meaning | Default |\n|---|---|---|\n| `PORTAL_BASH_HEARTBEAT_INTERVAL` | how often (s) a MCP progress notification is sent as keepalive during execution; independent of the server-side `timeout` | `5` |\n| `PORTAL_MAX_TIMEOUT` | **cap (s)** on the per-command foreground `timeout`. `timeout` is **required** (no default); over the cap is **refused** with a hint to use `remote_job`. A guardrail, not a default | built-in `300` |\n| `PORTAL_LOGIN_SHELL` | whether `remote_exec`'s normal path and `remote_job` default to a **login shell** (`bash -lc`), loading the user's profile PATH/env. Default **on**; only `0`/`false`/`no`/`off` disables. Priority: per-call `login` \u003e hosts.yaml `login_shell:` \u003e this var. sh-only hosts auto-fallback; `remote_shell` is unaffected (persistent session uses `--norc`) | `on` |\n\n### Shell session\n\nTiming knobs for `remote_shell` persistent sessions; normal deployments needn't touch them.\n\n| Variable | Meaning | Default |\n|---|---|---|\n| `PORTAL_SHELL_MAX_OUTPUT` | per-command in-memory output cap (bytes); over-limit drops the head and flags `truncated` | `8388608` (8 MiB) |\n| `PORTAL_SHELL_BOOT_TIMEOUT` | timeout to bring up a persistent session (inject the integration script + readiness marker) (s) | `10.0` |\n| `PORTAL_SHELL_BOOT_QUIET` | quiet confirmation window before bootstrap completes (s) | `0.6` |\n| `PORTAL_SHELL_INTERACTIVE_GRACE` | grace after spotting an interactive prompt before deciding it's wedged and soft-cancelling (s) | `1.0` |\n| `PORTAL_SHELL_SOFT_CANCEL_TIMEOUT` | timeout after soft-cancel (incl. foreground-timeout interrupt) waiting for the OSC133 `D` back to a clean prompt (s); on timeout the session is destroyed | `3.0` |\n\n### Testing (dev only)\n\nUsed only when running `tests/`.\n\n| Variable | Meaning | Default |\n|---|---|---|\n| `PORTAL_TEST_LIVE` | set `1`/`true`/`yes` to run the real-SSH tests in `tests/test_live_ssh.py`; else all skip | _(unset)_ |\n| `PORTAL_TEST_HOST` / `PORTAL_TEST_PORT` / `PORTAL_TEST_USER` / `PORTAL_TEST_KEY_PATH` | live-test target | `127.0.0.1` / `22` / `$USER` or `root` / `~/.ssh/id_ed25519` |\n\n## \u003ca id=\"authentication\"\u003e\u003c/a\u003eAuthentication\n\nJump by method — prefer SSH keys; passphrases prefer ssh-agent; password login\nsupports `password_command` or `portal ssh set`; plaintext passwords never reach\nthe LLM.\n\n### Credential-flow overview\n\nFive credential flows, each with a \"password-manager (command source)\" and a\n\"no-echo interactive (getpass + credential agent) source\":\n\n| Flow | Command source | No-echo interactive | Cache key | Cache semantics | Trigger |\n|---|---|---|---|---|---|\n| **A. Remote SSH login password** | `password_command` (hosts.yaml) | ✅ `portal ssh set \u003chost\u003e` | host | agent memory TTL (default 900 s, interactive only; command source fetches fresh) | `auth: password` connect / auto-fallback on key failure |\n| **B. SSH key passphrase** | `passphrase_command` (hosts.yaml) | ✅ `portal passphrase set \u003chost\u003e` | host | agent memory TTL (900 s) | local decrypt of an encrypted key |\n| **C. Remote sudo** | `sudo_password_command` (hosts.yaml) | ✅ `portal sudo set \u003chost\u003e` | host | agent memory TTL (900 s) | `remote_exec(use_sudo=True)` |\n| **C2. Local sudo** | top-level `\u003clocal\u003e:` `sudo_password_command` | ✅ `portal sudo set-local` | `\u003clocal\u003e` | agent memory TTL (900 s) | `local_exec(use_sudo=True)` |\n| **D/E. Secret injection (remote/local)** | `secrets.yaml` `command` | ✅ `portal secret set \u003cname\u003e` | name | agent memory TTL (900 s, `--ttl`) | `remote_exec` / `local_exec` `secrets=[…]` |\n\n- **A/B/C/D share one per-user agent socket**, but the agent keeps separate\n  `ssh`/`passphrase`/`sudo`/`secret` key spaces.\n- **A's fallback order**: `auth: password` login is `cache (portal ssh set) →\n  password_command → error`; a pure-key host auto-retries the password path once\n  on `PermissionDenied`, but only if a source exists, else the original error\n  propagates (so a missing config can't mask a real key failure).\n- **Interactive sources = per-user agent memory TTL** (default 900 s, never on\n  disk). **Command sources = fetched fresh, no TTL.**\n- **Plaintext never leaves the agent**: there is no `show plaintext`; `portal\n  {ssh,passphrase,sudo,secret} show \u003ckey\u003e` returns only a sha256[:16] fingerprint\n  + remaining TTL, `list` summarizes, `confirm` re-types and compares. See the\n  [Security](#security) section and [`SECURITY.en.md`](./SECURITY.en.md).\n\n\u003cdetails\u003e\u003csummary\u003eThe four credential mechanisms — implementation \u0026 why\u003c/summary\u003e\n\n| Type | Implementation | Why |\n|---|---|---|\n| **SSH login password** | asyncssh `password=` (SSH protocol level), source: `password_command` / `portal ssh set` cache | SSH natively supports password auth; the protocol frame is cleanest |\n| **SSH key passphrase** | asyncssh `passphrase=`, source: ssh-agent → `portal passphrase set` cache → `passphrase_command`; or `use_ssh_agent` | local key decrypt; cached separately so a key-unlock passphrase isn't confused with a login/sudo password |\n| **sudo password** | `sudo -S` fed on stdin (`conn.run(input=pw)`), source: `sudo_password_command` / `portal sudo set` cache | sudo only reads `-S`/`-A`/tty, not env; `-S` has the narrowest exposure (password lives briefly on stdin, nothing on disk, not in env) |\n| **secrets** (API tokens) | `bash -s` + stdin `export VAR=…\\n\u003ccmd\u003e\\n`, source: `secrets.yaml` `command` / `portal secret set` cache | tools read env (`GH_TOKEN`/`AWS_*`); the value stays briefly in the bash stdin script, not on argv (`ps`) or in logs |\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\u003csummary\u003e⚠️ Risk of configuring these passwords (read this)\u003c/summary\u003e\n\nKey-only login is the safest baseline. **Once you configure an SSH login\npassword / sudo password / secret for a host, you authorize \"any agent that can\ncall this MCP server\" to act with those credentials for their lifetime** — the\nagent won't ask again. A **permanent** command source (`sudo_password_command`\netc.) is fetched fresh with no TTL and is usable as long as your password store\nis unlocked; a **temporary** `portal … set` value lives in the per-user agent's\nmemory with a TTL and is dropped automatically, never on disk.\n\n\u003c/details\u003e\n\n### SSH key (preferred)\n\nUse ed25519 and distribute with `ssh-copy-id`; asyncssh discovers ssh-agent via\n`$SSH_AUTH_SOCK`. For headless/CI, write `passphrase_command:` in `hosts.yaml`.\n\n**The agent applies to all key auth in parallel, not just encrypted keys**: by\ndefault (`use_ssh_agent` omitted = auto) asyncssh tries local key files **and**\n`$SSH_AUTH_SOCK` together — any key you `ssh-add`ed authenticates to any key-auth\nhost, even with no `key:` in `hosts.yaml`. Tighten it per host with\n`use_ssh_agent`: omit = **auto** (files + agent in parallel); `true` =\n**agent-only** (no key files passed, the key never leaves the agent); `false` =\n**hard-disable** (key files only).\n\n### Password login: `password_command` or `portal ssh set`\n\nTwo rules: **never** put a plaintext `password:` in `hosts.yaml` (rejected at\nstartup, field dropped); **never** pass it through an MCP tool (`hosts` has no\npassword parameter). Two sources (order: agent cache → `password_command` → error):\n\n```yaml\nhosts:\n  legacy-host:\n    host: 10.0.0.40\n    user: admin\n    auth: password\n    password_command: pass show ssh/legacy-host   # or: bw get password … / op read op://… / printf '%s' \"$ENV\"\n```\n\nOr push interactively in **another terminal**: `portal ssh set legacy-host`\n(no-echo getpass, TTL cache), `portal ssh confirm/show/list/clear`. Key-mode hosts\nauto-fallback to the password path once on `PermissionDenied` when a source\nexists. Design details (why `shell=True`, forced `client_keys=[]`, stderr never\nlogged) are in [`SECURITY.en.md` § Authentication](./SECURITY.en.md).\n\n### Encrypted-key passphrase: `portal passphrase set` / `passphrase_command` / `use_ssh_agent`\n\nA passphrase is a **local key-unlock secret**, separate from a remote login /\nsudo password (separate agent kind). Full order: **ssh-agent → agent cache\n(`portal passphrase set`) → `passphrase_command` → asyncssh default**. Prefer\nssh-agent when available; `passphrase_command` is for headless/CI.\n\n```yaml\nhosts:\n  encrypted-key-host:\n    host: 10.0.0.30\n    user: deploy\n    key: ~/.ssh/encrypted_key\n    passphrase_command: pass show ssh/encrypted_key\n    use_ssh_agent: true   # true=agent only; false=disable agent; omit=auto\n```\n\n### Non-interactive sudo: `use_sudo` + `portal sudo set`\n\n`remote_exec(host, cmd, use_sudo=True)` runs a root command, but **the sudo\npassword never enters the LLM** (no password parameter; resolved server-side).\nSources: `sudo_password_command` in `hosts.yaml`, or `portal sudo set \u003chost\u003e`\n(no-echo, TTL cache). `sudo_password_same_as_ssh: true` makes `portal ssh set`\nalso cache the same value for `sudo` (config-only, default false; does not reuse\nthe private-key passphrase). Order: agent cache → `sudo_password_command` → error.\n\nImplementation: `use_sudo` runs a one-shot `conn.run(input=pw, …)` of\n`sudo -S -k -p '' -- bash -c \u003ccmd\u003e` — **not** the persistent `remote_shell`\nsession (a PTY can't feed the `-S` password). So a sudo command **doesn't inherit**\nprior `remote_shell` cwd/env; include `cd … \u0026\u0026 …` in the command if needed.\n\n#### Local sudo: `local_exec(use_sudo=True)`\n\nThe **local** counterpart on the MCP server's own machine, via local\n`sudo -S -k`, password also never in the LLM / argv / disk. Reserved identity\n**`\u003clocal\u003e`** (≠ an SSH host `local`/`localhost`). Source: `portal sudo set-local`\nor a top-level `\u003clocal\u003e:` section's `sudo_password_command` in hosts.yaml.\nCombinable with `secrets`; flagged `high_risk`, audited as `local_exec_sudo`.\n\n```yaml\nhosts:\n  # ... your remote hosts ...\n\"\u003clocal\u003e\":                                # top-level reserved key, for local_exec only\n  sudo_password_command: pass show sudo/this-box\n```\n\n### Named secret injection: `secrets=[…]` + `portal secret set`\n\nFor giving a command an API token without it entering session history or the\nthird-party LLM: the agent passes only the **name**, the server resolves the\nvalue and injects it as an **environment variable** into a one-shot command; any\necho of the value in output is redacted to `***` before returning.\n\n- Remote: `remote_exec(host, cmd, secrets=[\"github_token\"])`, write `$GITHUB_TOKEN`.\n- Local: `local_exec(cmd, secrets=[\"github_token\"])` on the MCP server's own\n  machine (off-target derivative, **off by default**, needs `PORTAL_ALLOW_LOCAL_EXEC=1`).\n\nTwo sources (order: agent cache → `secrets.yaml`):\n\n```yaml\nsecrets:\n  github_token:\n    command: pass show api/github      # or op read / printf \"$ENV\"\n```\n\nor `portal secret set github_token` (no-echo, TTL). Full config in\n[`examples/secrets.yaml`](./examples/secrets.yaml). `secrets` can combine with\n`use_sudo`.\n\n\u003cdetails\u003e\u003csummary\u003eImplementation: sudo + secrets coexistence, and the wait semantics\u003c/summary\u003e\n\n`use_sudo` and `secrets` share **one stdin**: the sudo password first, then each\nsecret value base64-encoded (one line each). The real command is prefixed with a\nsmall preamble that, after sudo's `env_reset`, reads each base64 line and decodes\nit inside the elevated shell — so a multi-line secret (PEM key, JSON blob)\nsurvives intact, the value never lands on argv (`ps`), and no sudoers `env_keep`\nis needed. Both `remote_exec` and `local_exec` implement this identically\n(`secrets_store.sudo_stdin_secret_script/_values`).\n\n**Wait semantics — fail-fast → ask_user → retry**: no-echo input inherently waits\nfor a human, but that wait never blocks the agent's critical path. If a secret /\nsudo password isn't ready, the tool **returns an error immediately** (value-free)\nsuggesting the agent use an interactive/choice tool (e.g. `ask_user`) to have the\nuser run `portal secret set \u003cname\u003e` / `portal sudo set \u003chost\u003e` in another terminal\nand confirm, then retry. **Never ask the user to paste the value into the\nconversation.** This guidance reaches the agent via each tool's own description\n(MCP's server-level `instructions` field is optional and not injected by Copilot\nCLI / Codex / Claude Code, so portal doesn't rely on it).\n\n\u003c/details\u003e\n\n### Host lookup: hosts.yaml + OpenSSH ssh config\n\n**Default order** (first hit wins): (1) `hosts.yaml` (from the XDG config dir);\n(2) OpenSSH ssh config (user `~/.ssh/config` + system fallback, mirroring `ssh -F`,\nparsed by asyncssh). `PORTAL_SSH_CONFIG=none` disables step 2. `hosts(action=\"list\")`\nlists both with a `source` field.\n\n**Priority**: by default a same-named `hosts.yaml` host **fully overrides** ssh\nconfig. **Per-host `use_ssh_config: true`** switches to **merge**: the ssh-config\nalias is the base (HostName / User / Port / IdentityFile / IdentityAgent /\nProxyJump …), with explicitly-set `hosts.yaml` fields overlaid. Footguns (all\nsurfaced as warnings via `hosts(action=list)`): a same-named host on both sides\nwithout `use_ssh_config` (hosts.yaml silently wins); `use_ssh_config: true` with\nno matching alias; `use_ssh_config: true` with a `host:` that disagrees with the\nalias's HostName (**hard error on connect**). Base fields\n(`host`/`port`/`user`/`key`/`known_hosts`/`strict_host_key_checking`/`auth`) plus\n`proxy_jump` / `keepalive_interval` / `forward_agent` / `use_ssh_agent` (omit=auto /\n`true`=agent-only / `false`=disable) are natively supported; other\nssh-config fields need the merge. `proxy_jump` uses **value semantics**: omitting it\ninherits the ssh-config `ProxyJump` (merge mode); `proxy_jump: none` **forces a direct\nconnection** (overriding it); an empty/`null` value is ambiguous (direct vs. inherit?)\nand is **rejected at connect time** — use `none` or drop the key.\n\n## \u003ca id=\"security\"\u003e\u003c/a\u003eSecurity\n\n- **Default sandbox**: writes default to remote `/tmp/`; the agent must ask\n  before touching `$HOME` or project source (enforced at the prompt layer — see\n  [Agent-side conventions](#agent-conventions)).\n- **Policy gate**: host allowlist + command blocklist/allowlist + per-host rate\n  limit; every state-changing tool passes `_gate` with no side doors\n  (`hosts(register)` gates the target IP not the alias; `remote_tunnel(close)`\n  gates too; multi-host is two-phase). The optional\n  [cc-safety-net](https://github.com/kenryu42/cc-safety-net) semantic gate\n  (`policies.safety_net.enabled`) stacks in the same place — bypass-resistant\n  analysis that catches destructive git/rm/interpreter one-liners, the same rules\n  the Copilot-CLI PreToolUse hook uses (which never sees portal MCP commands).\n  Fail-closed by default.\n- **Authentication**: SSH key by default and recommended; password login via\n  `password_command` or `portal ssh set`, never exposed to MCP tools — see\n  [Authentication](#authentication) and [`SECURITY.en.md` § Authentication](./SECURITY.en.md).\n- **HTTP transport (optional)**: binds `127.0.0.1` by default; a non-loopback\n  bind without `PORTAL_AUTH_TOKEN` **refuses to start**, and portal serves\n  plaintext HTTP so terminate TLS in front.\n- **Tunnel / transfer boundaries**: `remote_tunnel` binds loopback by default\n  (off-box / reverse exposure needs `PORTAL_ALLOW_TUNNEL_EXPOSURE=1`);\n  `remote_transfer` directory modes don't follow local symlinks (no escaping the\n  tree), but still have the server user's local filesystem reach (like `scp`).\n- **Audit**: state changes write `$PORTAL_LOG_DIR/audit.jsonl` (dir `0700` /\n  file `0600`). The audit write happens **after** the operation, so fail-closed is\n  **response-level** — a failed write makes the tool error to the agent, but the\n  remote change already happened (see [`SECURITY.en.md`](./SECURITY.en.md));\n  `PORTAL_AUDIT_FAIL_OPEN=1` switches to fail-open.\n- **Hash-protected edits**: `remote_read` + `remote_patch` use SHA-256 + per-range\n  hash + atomic `posix_rename` + post-write rehash to **detect** concurrent\n  overwrite / mid-write disconnect / line drift (optimistic, not a filesystem CAS;\n  plain writes don't preserve mode/owner).\n- **Remote bash-history risk (unusual remote config)**: `remote_exec(secrets=…)`\n  injection relies on remote bash **disabling history in non-interactive mode**\n  (bash upstream design, same premise as `ssh`/`ansible`/CI shell steps). If a\n  remote admin **forces** `BASH_ENV` + `set -o history`, any SSH-based secret\n  injection tool (this one, `ssh`, `ansible`, CI runners) could leak the value\n  into `~/.bash_history`. This is a Unix/SSH-ecosystem premise, not a\n  project-specific weakness. Verify with `bash -s \u003c\u003c\u003c 'echo test'`.\n\nFull threat model, per-layer detail, operator hygiene, known limitations and\nalgorithm provenance are in **[`SECURITY.en.md`](./SECURITY.en.md)**.\n\nVulnerability disclosure: **don't** open a public issue — use\n[GitHub Security Advisories](https://github.com/TMYTiMidlY/portal-mcp-server/security/advisories/new).\nResponse window: 48 h ack / 7 day assessment / 30 day critical fix.\n\n## \u003ca id=\"testing\"\u003e\u003c/a\u003eTesting\n\n### Unit + security (no real SSH)\n\n```bash\npytest tests/ -v\n# live SSH tests skip by default (gated by PORTAL_TEST_LIVE)\n```\n\nCovers: command-injection regression, safety validators, hash-protected editor,\nconcurrency, resource lifecycle, multi-host policy enforcement,\n`password_command`/`passphrase_command` security invariants, audit fail mode.\n\n### End-to-end live smoke\n\n`tests/live_smoke.py` drives real SSH behavior directly from the local tree.\n\n```bash\nPORTAL_AUDIT_FAIL_OPEN=1 \\\n  PORTAL_TEST_HOST=\u003cyour-host\u003e PORTAL_TEST_PORT=22 PORTAL_TEST_USER=\u003cuser\u003e \\\n  PORTAL_TEST_KEY_PATH=$HOME/.ssh/id_ed25519 \\\n  uv run --with-editable . --with pytest --with pytest-asyncio \\\n    python tests/live_smoke.py\n```\n\n⚠️ It writes once under remote `/tmp/portal-mcp-server-smoke-\u003cpid\u003e.txt` then\nremoves it — `/tmp` only.\n\n## \u003ca id=\"ci-release\"\u003e\u003c/a\u003eCI / Release\n\n- **CI** ([`ci.yml`](.github/workflows/ci.yml)): every PR / push to `main` runs\n  `ruff check portal_mcp_server/ tests/` + `pytest tests/` on Python\n  **3.10 / 3.11 / 3.12 / 3.13** (ubuntu), plus a macOS full-suite job and a\n  Windows named-pipe / scheduled-task job; all green to merge.\n- **Release** ([`release.yml`](.github/workflows/release.yml)): pushing a `v*` tag\n  (incl. PEP 440 pre/dev/post, e.g. `v4.0.0a0`) triggers `python -m build` (wheel +\n  sdist) → GitHub Release body from the matching `CHANGELOG.md` section → publish\n  to [PyPI](https://pypi.org/project/portal-mcp-server/) via\n  [trusted publishing](https://docs.pypi.org/trusted-publishers/) (OIDC, no static\n  token).\n\nFull release flow, CHANGELOG format constraints and failure triage are in\n[`CONTRIBUTING.en.md` § CI \u0026 Release automation](./CONTRIBUTING.en.md).\n\n## \u003ca id=\"faq\"\u003e\u003c/a\u003eFAQ\n\n### Local changes don't show up in the agent\n\nWhether run via the `uv tool install`'d `portal-mcp-server` or `uvx`, both use the\n**PyPI-published build**, not your working tree — local edits aren't seen. For\nlocal debugging, either do an editable install (recommended; `command` stays\n`portal-mcp-server`):\n\n```bash\nuv tool install --force --editable .\n```\n\nor, with uvx, temporarily set `.mcp.json` `args` to\n`[\"--from\", \"/absolute/path/to/portal-mcp-server\", \"portal-mcp-server\"]` (absolute\npath). **Don't commit that local path into a project `.mcp.json`.**\n\n### Connection timeout / Permission denied (publickey)\n\n1. Confirm `ssh user@host` connects directly in a terminal.\n2. Check key perms: `chmod 600 ~/.ssh/id_ed25519`.\n3. If using `~/.ssh/config`, confirm the `Host` alias / `HostName` / `User` /\n   `IdentityFile`.\n4. For ProxyJump, asyncssh honors `~/.ssh/config`'s `ProxyJump`; confirm the\n   bastion connects manually too. **Mind the jump-credential boundary**: a bare\n   `proxy_jump: user@jump` in hosts.yaml reaches the bastion with the **default\n   key/agent** only, and reuses the passphrase resolved for the **target** to\n   unlock **the local key that logs into the bastion** (a differently-encrypted\n   bastion key then fails with `Incorrect passphrase`); it does **not** read a\n   bastion-specific `IdentityFile`. To give the bastion its own key/passphrase,\n   set `use_ssh_config: true` (asyncssh then reads the bastion's `Host`\n   `IdentityFile`) and load the bastion key into **ssh-agent** (agent auth needs\n   no passphrase, so the clash disappears). To force a direct connection\n   (ignoring an ssh-config `ProxyJump`), set `proxy_jump: none`.\n\n### Connection drops after the MCP client restarts\n\nExpected — the pool follows the MCP server process lifecycle. A client restart\ncloses the server; the next tool call rebuilds connections automatically.\n\n### Update to the latest version\n\n```bash\nuv tool upgrade portal-mcp-server      # installed (recommended); or uv tool upgrade --all\nuvx portal-mcp-server@latest --help    # zero-install: refresh the uvx cache\n```\n\nThen restart the MCP client.\n\n## \u003ca id=\"contributing\"\u003e\u003c/a\u003eContributing\n\nIssues and PRs welcome. Short version:\n\n- Python 3.10+, all I/O `async/await`, no blocking calls.\n- No hard-coded hostname / username / IP / path.\n- New tools need a good docstring (FastMCP uses it as the MCP description) + a\n  README \"Tools\" update (incl. the folded full signature + source-map table).\n- State-changing tools must pass `_gate` + write `audit_log`.\n- Tests cover key paths; `pytest tests/ -v` must be all green.\n- Don't commit secrets; `examples/hosts.yaml` is the one schema template.\n- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/).\n\nFull dev flow, new-tool checklist, PR template, and security / privacy rules are\nin **[`CONTRIBUTING.en.md`](./CONTRIBUTING.en.md)** ([简体中文](./CONTRIBUTING.md)).\n\n## \u003ca id=\"license-credits\"\u003e\u003c/a\u003eLicense \u0026 credits\n\nApache License 2.0 (see [`LICENSE`](LICENSE)).\n\nDerivation and third-party algorithm provenance are in [`NOTICE`](NOTICE):\n\n- **[`jaguar999paw-droid/ssh-shell-mcp`](https://github.com/jaguar999paw-droid/ssh-shell-mcp)\n  (Apache 2.0)** — git ancestry; the underlying modules (asyncssh engine, pool,\n  tunnel management, orchestrator, security policy) are carried over; the 14\n  portal tools on top are a new design.\n- **[`tumf/mcp-text-editor`](https://github.com/tumf/mcp-text-editor) (MIT)** —\n  the SHA-256 hash-protected edit algorithm behind `remote_text_editor.py`,\n  rewritten for AsyncSSH SFTP.\n\n\u003e ⚠️ This tool gives an agent SSH access to remote systems. Use it only on\n\u003e systems you own or are authorized to access.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftmytimidly%2Fportal-mcp-server","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftmytimidly%2Fportal-mcp-server","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftmytimidly%2Fportal-mcp-server/lists"}