Skip to main content
Glama
TMYTiMidlY

portal-mcp-server

by TMYTiMidlY

Server Configuration

Describes the environment variables required to run the server.

NameRequiredDescriptionDefault
PORTAL_JOB_TTLNoSeconds before completed jobs are cleaned up. Default: 3600.
PORTAL_LOG_DIRNoDirectory for audit and server logs. Default: ~/.local/state/portal-mcp-server/log/
PORTAL_TEST_HOSTNoTarget host for live tests. Default: 127.0.0.1
PORTAL_TEST_LIVENoSet to '1' to enable live SSH tests. For dev only.
PORTAL_TEST_PORTNoPort for live tests. Default: 22
PORTAL_TEST_USERNoUser for live tests. Default: $USER or root
PORTAL_AUTH_TOKENNoBearer token for HTTP transport. Not used in stdio mode.
PORTAL_HOSTS_YAMLNoPath to hosts YAML file. Default: ~/.config/portal-mcp-server/hosts.yaml
PORTAL_SSH_CONFIGNoPath to OpenSSH client config. Default: ~/.ssh/config. Set to 'none' to disable ssh config lookup.
PORTAL_JOB_PERSISTNoSet to '0' or 'false' to disable job table persistence across restarts. Default: enabled.
PORTAL_JOB_MAX_LIVENoMax concurrent live background jobs. Default: 50.
PORTAL_SECRETS_YAMLNoPath to secrets YAML file. Default: ~/.config/portal-mcp-server/secrets.yaml
PORTAL_AUDIT_BACKUPSNoNumber of rotated audit files to keep. Default: 5.
PORTAL_POLICIES_YAMLNoPath to policies YAML file. Default: ~/.config/portal-mcp-server/policies.yaml
PORTAL_SSH_POOL_SIZENoMax TCP connections per host. Default: 5.
PORTAL_TEST_KEY_PATHNoKey path for live tests. Default: ~/.ssh/id_ed25519
PORTAL_JOB_STATE_FILENoPath to job state file. Default: <state>/jobs.json
PORTAL_READ_MAX_BYTESNoMax bytes per page for portal_read. Default: 16384.
PORTAL_READ_MAX_LINESNoMax lines per page for portal_read. Default: 2000.
PORTAL_AUDIT_FAIL_OPENNoSet to '1' to continue on audit write failure. Default: unset (fail-closed).
PORTAL_AUDIT_MAX_BYTESNoMax bytes for audit.jsonl rotation. Default: 10485760 (10 MiB).
PORTAL_DEFAULT_TIMEOUTNoDefault timeout in seconds for exec/shell/local_exec. Default: 3600.
PORTAL_ALLOW_LOCAL_EXECNoSet to '1' to enable portal_local_exec. Default: disabled.
PORTAL_SSH_MAX_CONN_AGENoMax connection lifetime in seconds. Default: 3600.
PORTAL_SSH_MAX_IDLE_TIMENoSeconds before idle connection is closed. Default: 600. Set 0 to disable.
PORTAL_BASH_HEARTBEAT_INTERVALNoSeconds between keepalive heartbeats during shell/exec. Default: 5.
PORTAL_CREDENTIAL_AGENT_SOCKETNoPath to credential agent unix socket. Default: auto-detected via agent.json.
PORTAL_SSH_MAX_CHANNELS_PER_CONNNoMax concurrent channels per TCP connection. Default: 5.

Capabilities

Features and capabilities supported by this server

CapabilityDetails
tools
{
  "listChanged": false
}
prompts
{
  "listChanged": false
}
resources
{
  "subscribe": false,
  "listChanged": false
}
experimental
{}

Tools

Functions exposed to the LLM to take actions

NameDescription
portal_hostA

Manage the SSH host registry.

Modes

  • action="list": list all known hosts. Includes hosts.yaml / runtime- registered entries AND every Host alias discoverable in the OpenSSH client config (~/.ssh/config + system-wide fallback; or only $PORTAL_SSH_CONFIG when it is an absolute path; or none of them when it is "none" — the ssh -F none analogue), resolving real HostName/User/Port. Each entry has a source field: "hosts.yaml", "runtime", "ssh-config", or a "…+ssh-config" overlay (metadata from the declared origin, connection params from ssh config). Example: portal_host(action="list")

  • action="register": add a host to the registry. Pass host (ip/hostname), or just name if ~/.ssh/config has a matching Host alias (registers a use_ssh_config overlay). Optional: user (default root), port (default 22), key_path (else asyncssh falls back to ~/.ssh/id_* or ssh-agent), tags (comma-separated, used by portal_exec's group_tag). Example: portal_host(action="register", name="web01", host="10.0.0.1", user="ubuntu", tags="web,prod")

  • action="remove": remove a host from the registry. Required: name. Example: portal_host(action="remove", name="web01")

Hosts already defined in ~/.ssh/config are auto-resolved on first use; explicit registration is only needed for tag-based grouping. This MCP tool only accepts key-based hosts — password auth is intentionally not exposed here so credentials cannot leak into LLM tool-call traces. To use password auth, declare the host in hosts.yaml with auth: password and a password_command:. See README §Authentication.

action="list" may include a per-host warnings array (e.g. a plaintext password: field in hosts.yaml that is being ignored). When present, relay these warnings to the user — they flag misconfigurations the user must fix, and server-side logs are not visible to them.

portal_transferA

Transfer files between local and remote via SFTP (binary-safe, atomic).

Modes

  • direction="upload": local_path → remote_path (single file). Example: portal_transfer(direction="upload", host="web01", local_path="/tmp/app.jar", remote_path="/opt/app/app.jar")

  • direction="download": remote_path → local_path (single file). Example: portal_transfer(direction="download", host="web01", remote_path="/var/log/syslog", local_path="/tmp/syslog")

  • direction="sync": recursively sync local_path directory → remote_path directory (upload). Files already present with a matching size+mtime (or sha256 when checksum=True) are skipped.

  • direction="mirror": recursively mirror remote_path directory → local_path directory (download); the remote→local counterpart of "sync". Example: portal_transfer(direction="mirror", host="web01", remote_path="/srv/www/", local_path="./www/")

  • direction="upload-list" / "download-list": transfer an explicit list of file pairs given in paths_json (an arbitrary local→remote mapping, not a whole directory). Each pair is skipped when already present with a matching size+mtime (or sha256 when checksum=True), so re-runs only move the changed files; a single pair's failure is collected in failed[] without aborting the batch. local_path / remote_path are ignored in these modes. Example: portal_transfer(direction="upload-list", host="web01", paths_json='[{"local":"/tmp/a.conf","remote":"/etc/app/a.conf"}, {"local":"/tmp/b.conf","remote":"/etc/app/b.conf"}]')

Args: checksum: for the incremental modes (sync/mirror/upload-list/ download-list), compare files by sha256 instead of size+mtime (slower, requires sha256sum on the remote; missing remote files or an unavailable sha256sum force a re-transfer). paths_json: JSON array of {"local": ..., "remote": ...} objects, required by the upload-list / download-list modes (ignored otherwise).

Progress is reported to the MCP client during transfers.

Returns a JSON status dict. Single-file: {status, direction, host, bytes, duration_s, ...}. sync/mirror/upload-list/download-list: {status, uploaded|downloaded, skipped, failed[], bytes_total, bytes_transferred, duration_s}.

For text-only edits prefer portal_patch (hash-protected). Use portal_transfer when SFTP semantics are needed: binary files, large files, whole directory trees. Note: directory modes copy files only — symlinks and special files are skipped, and empty directories are not created on their own.

portal_tunnelA

Manage SSH tunnels — a single entry point (like portal_host) where action selects the operation and the other args parameterise it.

Actions

  • action="open": open a tunnel through host. kind picks the type:

    • kind="local" : forward localhost:local_port → remote_host:remote_port via host. Required: remote_host, remote_port (local_port 0 = auto). Example: portal_tunnel(action="open", kind="local", host="bastion", local_port=5432, remote_host="db.internal", remote_port=5432)

    • kind="reverse": expose local_bind:local_port to host as host:remote_port. Required: remote_port, local_bind, local_port. Example: portal_tunnel(action="open", kind="reverse", host="bastion", remote_port=8080, local_bind="127.0.0.1", local_port=3000)

    • kind="socks" : SOCKS5 proxy on localhost:local_port via host. Required: local_port (default 1080). Example: portal_tunnel(action="open", kind="socks", host="bastion", local_port=1080)

    Returns {tunnel_id, type, host, local, remote}.

  • action="close": close a live tunnel. Required: tunnel_id (from open). Example: portal_tunnel(action="close", tunnel_id="ab12cd34")

  • action="list": list all active tunnels (JSON array).

Tunnels are a resource you manage explicitly (open → close), so list lives here rather than in portal_audit.

portal_checkA

Dry-run a host (and optional command) through the security policy.

  • command="" : check whether the host is accessible at all. Example: portal_check(host="web01")

  • command="rm -rf /" : check whether this command would be allowed on this host. Example: portal_check(host="web01", command="systemctl stop nginx")

Returns "ALLOWED" or "BLOCKED: ". Does not execute anything. Use this before risky multi-host operations to surface policy errors early. Being a dry-run, it does NOT consume a rate-limit token (a pre-flight check never throttles the real operation it is checking for).

⚠️ Default policy is PERMISSIVE — out of the box policies.yaml has an empty host_allowlist (any host), empty command_blocklist / allowlist (any command), and only a per-host rate limit. So portal_check will return ALLOWED for almost anything until you populate $XDG_CONFIG_HOME/portal-mcp-server/policies.yaml (default ~/.config/portal-mcp-server/policies.yaml) with explicit rules. Use portal_audit(view="policy") to inspect what the server actually has loaded. ALLOWED therefore means "no rule currently blocks this", not "this is safe to run".

portal_auditA

Inspect MCP server internal state and the audit log — the read-only introspection hub for plumbing (the connection pool, persistent bash sessions) and history (audit log, stats, policy).

Note the "resource vs plumbing" split: things the agent manages explicitly (registered hosts, open tunnels) are listed by their own resource tools — portal_host(action="list") and portal_tunnel(action="list") — NOT here. portal_audit only surfaces server-internal plumbing the agent never explicitly creates.

Views

  • view="snapshot" (default): server metadata + connection pool + bash sessions + audit stats + security policy summary. Use this for an all-at-once diagnostic. (Hosts and tunnels are intentionally absent — see the note above.)

  • view="server": just the server-level metadata (version, python_version, pid, started_at, uptime_s, transport, resolved config paths). Cheap; use this when you only need to know "which version am I talking to?" without pulling the full snapshot.

  • view="sessions": the host → session_id map of cached persistent bash sessions (what portal_shell reuses per host). This is plumbing diagnostics — the sessions are implicit, which is why they live in portal_audit rather than carrying their own list like tunnels/hosts.

  • view="history": last limit audit log entries (default 50). Optional host_filter. Example: portal_audit(view="history", limit=20, host_filter="web01")

  • view="stats": aggregate audit stats (counts by operation, error rate, etc.).

  • view="policy": current security policy details (host allowlist, command blocklist, allowlist, rate limit).

Read-only. Used to introspect what the MCP server has been doing and what limits are in place.

portal_readA

Read a file (or a 1-based line range) from a remote host with SHA-256 hashes.

Returns JSON with: content, file_hash, range_hash, start, end, total_lines, truncated. The file_hash MUST be supplied to portal_patch; if the file changed in between, portal_patch will refuse to overwrite.

The read is paged so a large file never has to come back as one oversized blob: a single call returns at most limit lines (default PORTAL_READ_MAX_LINES=2000) and at most PORTAL_READ_MAX_BYTES (default 16384) of content, whichever binds first — but always at least one line. When the page stops before the requested end, truncated is true and next_start gives the line to continue from (pass it back as start). Pages are cut on line boundaries, so range_hash stays valid for patching.

Usage: * Whole file (auto-paged): call portal_read(host, path); if the result has truncated: true, call again with start=<next_start> and repeat until truncated is false, concatenating each page's content in order. * A specific range: portal_read(host, path, start=40, end=80) (still paged if that range is huge). * Just the first N lines: portal_read(host, path, limit=N) — handy to peek the head of a big file without pulling it all.

Args: host: SSH host alias (from ~/.ssh/config) or registered host name path: Absolute remote path start: 1-based starting line (default 1) end: 1-based ending line of the requested range, inclusive (default: end of file) limit: max number of lines to return in this page (default: the PORTAL_READ_MAX_LINES cap). The byte cap may shorten the page further. encoding: Text encoding (default utf-8)

portal_patchA

Apply patches to a remote file with hash-based conflict detection.

Workflow:

  1. Call portal_read to obtain content + file_hash + range_hash for each region.

  2. Call portal_patch with the SAME file_hash and per-patch range_hash.

  3. If the file was modified by anyone else in between, this call returns {"result": "error", "reason": "Content hash mismatch ...", "current_file_hash": ...} — re-read and try again. The remote file is untouched.

patches_json must decode to a list of patch objects: [{"start": int, "end": int|null, "contents": str, "range_hash": str}, ...]

Notes:

  • Patches are applied bottom-to-top so line numbers stay valid.

  • Overlapping patches are rejected.

  • Writes are atomic (tmp file + rename) and re-hashed after write.

  • When auto_newline is true, missing trailing newlines on patch contents are auto-appended only if the slice they replace ended with one. The result includes a "warnings" list either way.

portal_grepA

Search file contents with a regex on a remote host (ripgrep, fallback grep). Prefer this over running raw rg/grep through portal_exec — it returns structured JSON and caps output so a broad search can't blow up your context. Pair it with portal_glob to find files by name.

Args: host: SSH host alias / registered name. pattern: the regex to search for (rg/PCRE-ish syntax). path: file or directory to search under (default: cwd "."). Result paths are returned relative to it. glob: filter files by a glob, e.g. ".py" or "!.test.ts". file_type: rg --type filter, e.g. "py", "rust", "js". output_mode: - "files_with_matches" (default): just the matching file paths, NEWEST FIRST. Cheapest; use it to locate, then re-grep with output_mode="content" on the file you care about. - "content": matching lines as {file, line, text} (context lines carry "context": true). head_limit caps the TOTAL lines returned and offset pages through them. - "count": per-file match counts + a grand total. ignore_case: case-insensitive match. before_context / after_context / context: lines of context to include around each match in "content" mode (context = both sides). head_limit: cap on results (files / content lines / count rows). Default 250; a truncated flag in the result tells you more were dropped. offset: skip the first offset results (pagination). multiline: let . and the pattern span line boundaries.

Respects .gitignore (ripgrep's default). Returns JSON whose shape depends on output_mode; every shape includes a truncated flag.

portal_globA

Find files by a glob pattern on a remote host, newest first. Prefer this over running raw find/ls through portal_exec — it returns structured JSON, sorts by modification time, and hard-caps at 100 files. Use portal_grep when you need to match file contents instead.

Args: host: SSH host alias / registered name. pattern: a glob, e.g. "/*.py", "src//.{ts,tsx}", ".toml". path: directory to search under (default: cwd "."). Returned filenames are relative to it.

Returns {filenames:[…newest first], num_files, truncated, duration_ms}. Unlike portal_grep this does NOT respect .gitignore (matches CC Glob).

portal_shellA

Run a command (or a sequence) on ONE remote host in a persistent shell session: cwd and environment (cd / export / venv activation) survive across calls. Use it only when you need that continuity — otherwise portal_exec is faster (no session setup) and can target many hosts; for a long task to background and poll, use portal_job.

Pick one:

  • command="pytest" → {host, session_id, command, exit_code, output, duration_s}.

  • commands=["cd /app","source .venv/bin/activate","pytest"] → steps run in order in the SAME session, so cwd / exports / venv carry across them (what portal_exec's multi-command path cannot do). Returns {host, session_id, results:[…]}; stop_on_error=True (default) halts at the first failure and adds stopped_at.

Behavior:

  • bash or zsh; an unknown login shell falls back to bash (if bash is missing the call is refused — use portal_exec).

  • Output is the COMBINED stdout+stderr stream (use portal_exec when you need them split). Oversize output is capped and flagged truncated=true.

  • A command that wedges on an interactive prompt (sudo password, ssh first-connect, passphrase, …) is auto-aborted, returning exit_code -1 + error:"interactive_prompt_blocked" + session_preserved:true — cwd/env survive, so the next command runs straight away.

★ No sudo or secret injection here (both are one-shot by nature): to run as root use portal_exec(use_sudo=True); for a command needing a secret use portal_exec(secrets=[…]).

timeout (per command, seconds; default 1h, operator-lowerable via PORTAL_DEFAULT_TIMEOUT): the call is held open until the command exits or timeout elapses. Keepalive pings stop the client aborting a hung call, so timeout is your real cut-off — for an exploratory / re-runnable command pass a SMALL one first (e.g. 10–30) to fail fast, raising it only for genuinely slow commands.

⚠️ By convention, write operations should target /tmp/ on the remote unless the user approved another path (not enforced here).

portal_execA

Run a shell command on one or more remote hosts over SSH and get the result immediately (exit code + SEPARATE stdout/stderr). This is the default way to execute ANYTHING on a remote machine — reach for it instead of hand-rolling ssh user@host …; it reuses a warm connection pool and fans out across hosts. Stateless: need cwd/exports to persist → portal_shell; a long task to background and poll → portal_job.

★ sudo: to run a command as root, call this with use_sudo=True — NEVER put a bare sudo … in the command or in portal_shell (there is no TTY and the password can't be fed). The password is supplied out-of-band; you never pass it. (For root on the server's OWN machine, sudo is not available — see portal_local_exec.)

★ credentials: when a command needs a secret (API token, deploy key, …), do NOT have the user paste it into the chat. They run portal secret set <name> in their own terminal; you pass secrets=[""] and reference it as the uppercased env var ($NAME).

Targets (pick one): host="web01" | host=["web01","web02"] | group_tag="prod" (all registered hosts carrying that tag).

Commands (pick one): command="uptime" (a single command; a multi-line string runs as one bash script) | commands=["apt update","apt upgrade"] (a sequence, each with its own exit code, stopping at the first failure when stop_on_error=True). Prefer the commands=[…] array over packing several lines into one string — it can't be silently flattened and you get per-step exit codes (matters most with use_sudo, where each entry runs as its own sudo command).

Multi-host fan-out is parallel by default; serialize=True (+ delay_s, stop_on_error) does a rolling, stop-on-first-failure rollout.

timeout (seconds, default 1h, operator-lowerable via PORTAL_DEFAULT_TIMEOUT): the call is held open until the command finishes or timeout elapses. Keepalive pings stop the client aborting a hung call, so timeout is your real cut-off — for an exploratory / re-runnable command pass a SMALL one first (e.g. 10–30) to fail fast, raising it only for commands you know are slow; keep it generous for ones whose mid-flight kill is dangerous (migrations, package upgrades).

use_sudo: run via sudo -S, with the password resolved out-of-band per host (the per-user credential agent from portal sudo set <host>, or the host's sudo_password_command) — never from you. Refused with guidance if no source is set. Consumes the command's stdin (tools that read their own stdin aren't supported under sudo). Cannot be combined with secrets.

secrets: a list of NAMES, not values (e.g. ["github_token"]); each is resolved server-side, fed over SSH stdin (never on argv/audit), exported as its uppercased env var ($GITHUB_TOKEN), and redacted to *** in the output. Cannot be combined with use_sudo.

Returns one dict {host, command, exit_code, stdout, stderr, elapsed_s} for a single host + command, else a JSON list (a multi-command host carries {host, results:[…]}).

★ When use_sudo or secrets is used the result is flagged "high_risk": briefly tell the user you ran a privileged / credentialed command with their stored sudo password / secret, or only do so with their explicit prior permission.

portal_local_execA

Run a command on the MCP SERVER's OWN machine (local), NOT over SSH — for anything on a remote host use portal_exec instead. Off by default (local execution is a larger threat surface): the operator must set PORTAL_ALLOW_LOCAL_EXEC=1 in the server process's env to enable it.

★ credentials: if the command needs a secret, don't have the user paste it into the chat — they run portal secret set <name> in their own terminal; you pass secrets=[""] and reference it as the uppercased env var ($NAME), injected into the local child's env and redacted from output. This lets a local script use an API token without the value ever entering this conversation.

No sudo here — for a privileged command, sudo is remote-only via portal_exec(use_sudo=True).

timeout (seconds, default 1h, operator-lowerable via PORTAL_DEFAULT_TIMEOUT): held open until the command exits or timeout elapses. Keepalive pings stop the client aborting a hung call, so for an exploratory / re-runnable command pass a SMALL one first (e.g. 10–30) to fail fast, raising it only for slow commands.

★ secrets flags the result "high_risk": briefly tell the user you ran a local command with their stored credential, or only do so with prior permission.

portal_close_shellA

Close the cached persistent bash session for (the next portal_shell call reopens a fresh one).

Rarely needed: the session is created/reused/auto-recreated implicitly by portal_shell — you don't manage its lifecycle. Use this only to reset a session whose state has gotten dirty.

portal_jobA

Run a command in the background and get a job_id back immediately, so you can keep thinking while it runs, poll for incremental output, and cancel it. Use this for long tasks; for a command that finishes quickly just use portal_exec (it waits and returns the result).

Actions

  • action="submit": start command on host in the background (nohup + remote tmp files), returning {job_id, host, remote_pid, started_at, status} right away. The job keeps running even if the SSH connection drops. NOTE: sudo / secret injection are NOT supported in the background and passing use_sudo=True or secrets=[...] here is rejected with guidance (sudo -S needs a stdin the nohup process detaches; secrets would land on argv / ps for the whole job) — run those with portal_exec (one-shot) or portal_shell instead.

  • action="poll": fetch this job's status + new output on demand, not all at once. Required: job_id. Pass since= to get only the bytes produced since then; each poll returns at most max_bytes (default 64 KiB) so a big backlog doesn't dump in one shot. Keep polling with since=new_offset while the returned more is true to drain the rest. Or pass tail=N to just peek the last N lines. Returns {status: running|done|failed|cancelled| unknown, exit_code?, output_chunk, new_offset, more, finished_at?}.

  • action="cancel": signal the job. Required: job_id. signal=TERM (default) or KILL. Best-effort — kill doesn't guarantee instant death; poll to confirm. Returns {job_id, signal_sent, status_after}.

  • action="list": list all known jobs {job_id, host, status, started_at, age_s, exit_code?}.

Limits (L1): job_ids are best-effort persisted across a server restart (the table reloads from /jobs.json and a poll re-probes the remote PID); set PORTAL_JOB_PERSIST=0 to disable. It's not a durable queue — a crash mid-write loses the view, but the remote process keeps running and is recoverable via ps. Finished jobs are swept after a TTL (default 1h) and their tmp files removed. There is a cap on concurrent live jobs (default 50).

Manual fallback (no portal_job): you can always background a command yourself with portal_exec(command="nohup mycmd >/tmp/x.log 2>&1 & echo $!") and poll the log with portal_exec(command="tail /tmp/x.log").

Prompts

Interactive templates invoked by user choice

NameDescription

No prompts

Resources

Contextual data attached and managed by the client

NameDescription

No resources

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/TMYTiMidlY/portal-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server