portal-mcp-server
Server Configuration
Describes the environment variables required to run the server.
| Name | Required | Description | Default |
|---|---|---|---|
| PORTAL_JOB_TTL | No | Seconds before completed jobs are cleaned up. Default: 3600. | |
| PORTAL_LOG_DIR | No | Directory for audit and server logs. Default: ~/.local/state/portal-mcp-server/log/ | |
| PORTAL_TEST_HOST | No | Target host for live tests. Default: 127.0.0.1 | |
| PORTAL_TEST_LIVE | No | Set to '1' to enable live SSH tests. For dev only. | |
| PORTAL_TEST_PORT | No | Port for live tests. Default: 22 | |
| PORTAL_TEST_USER | No | User for live tests. Default: $USER or root | |
| PORTAL_AUTH_TOKEN | No | Bearer token for HTTP transport. Not used in stdio mode. | |
| PORTAL_HOSTS_YAML | No | Path to hosts YAML file. Default: ~/.config/portal-mcp-server/hosts.yaml | |
| PORTAL_SSH_CONFIG | No | Path to OpenSSH client config. Default: ~/.ssh/config. Set to 'none' to disable ssh config lookup. | |
| PORTAL_JOB_PERSIST | No | Set to '0' or 'false' to disable job table persistence across restarts. Default: enabled. | |
| PORTAL_JOB_MAX_LIVE | No | Max concurrent live background jobs. Default: 50. | |
| PORTAL_SECRETS_YAML | No | Path to secrets YAML file. Default: ~/.config/portal-mcp-server/secrets.yaml | |
| PORTAL_AUDIT_BACKUPS | No | Number of rotated audit files to keep. Default: 5. | |
| PORTAL_POLICIES_YAML | No | Path to policies YAML file. Default: ~/.config/portal-mcp-server/policies.yaml | |
| PORTAL_SSH_POOL_SIZE | No | Max TCP connections per host. Default: 5. | |
| PORTAL_TEST_KEY_PATH | No | Key path for live tests. Default: ~/.ssh/id_ed25519 | |
| PORTAL_JOB_STATE_FILE | No | Path to job state file. Default: <state>/jobs.json | |
| PORTAL_READ_MAX_BYTES | No | Max bytes per page for portal_read. Default: 16384. | |
| PORTAL_READ_MAX_LINES | No | Max lines per page for portal_read. Default: 2000. | |
| PORTAL_AUDIT_FAIL_OPEN | No | Set to '1' to continue on audit write failure. Default: unset (fail-closed). | |
| PORTAL_AUDIT_MAX_BYTES | No | Max bytes for audit.jsonl rotation. Default: 10485760 (10 MiB). | |
| PORTAL_DEFAULT_TIMEOUT | No | Default timeout in seconds for exec/shell/local_exec. Default: 3600. | |
| PORTAL_ALLOW_LOCAL_EXEC | No | Set to '1' to enable portal_local_exec. Default: disabled. | |
| PORTAL_SSH_MAX_CONN_AGE | No | Max connection lifetime in seconds. Default: 3600. | |
| PORTAL_SSH_MAX_IDLE_TIME | No | Seconds before idle connection is closed. Default: 600. Set 0 to disable. | |
| PORTAL_BASH_HEARTBEAT_INTERVAL | No | Seconds between keepalive heartbeats during shell/exec. Default: 5. | |
| PORTAL_CREDENTIAL_AGENT_SOCKET | No | Path to credential agent unix socket. Default: auto-detected via agent.json. | |
| PORTAL_SSH_MAX_CHANNELS_PER_CONN | No | Max concurrent channels per TCP connection. Default: 5. |
Instructions
Guidance the server publishes about itself, which clients place ahead of the tool catalog so the model reads it before choosing anything.
This server publishes no instructions, or was last inspected before Glama recorded them.
Capabilities
Features and capabilities supported by this server
Protocol revision2025-11-25
| Capability | Details |
|---|---|
| tools | {
"listChanged": false
} |
| prompts | {
"listChanged": false
} |
| resources | {
"subscribe": false,
"listChanged": false
} |
| experimental | {} |
Tools
Functions exposed to the LLM to take actions
| Name | Description |
|---|---|
| hosts | Manage the SSH host registry. Modes
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 action="list" may include a per-host |
| remote_transfer | Transfer files between local and remote via SFTP (binary-safe, atomic). Modes
Args:
checksum: for the incremental modes (sync/mirror/upload-list/
download-list), compare files by sha256 instead of size+mtime
(slower, requires 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 remote_patch (hash-protected). Use remote_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. |
| remote_tunnel | Manage SSH tunnels — a single entry point (like hosts) where
Actions
Tunnels are a resource you manage explicitly (open → close), so |
| policy_checkA | Dry-run a host (and optional command) through the security policy.
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 |
| inspect | 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 — hosts(action="list") and remote_tunnel(action="list") — NOT here. inspect only surfaces server-internal plumbing the agent never explicitly creates. Views
Read-only. Used to introspect what the MCP server has been doing and what limits are in place. |
| remote_read | 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 remote_patch; if the file changed in between, remote_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 Usage:
* Whole file (auto-paged): call 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)
use_sudo: read via |
| remote_patch | Apply patches to a remote file with hash-based conflict detection. Workflow:
patches_json must decode to a list of patch objects: [{"start": int, "end": int|null, "contents": str, "range_hash": str}, ...] Notes:
|
| remote_grep | Search file contents with a regex on a remote host (ripgrep, fallback
grep). Prefer this over running raw 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). Respects |
| remote_glob | Find files by a glob pattern on a remote host, newest first.
Prefer this over running raw 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 remote_grep this does NOT respect |
| remote_shell | 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 remote_exec is faster (no session setup) and can target many hosts; for a long task to background and poll, use remote_job. Pick one:
Behavior:
★ No sudo or secret injection here (both are one-shot by nature): to run as root use remote_exec(use_sudo=True); for a command needing a secret use remote_exec(secrets=[…]). timeout (per command, seconds; REQUIRED — no default): 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 — pass a SMALL one for an exploratory / re-runnable command (e.g. 10–30) to fail fast, raising it only for a genuinely slow but bounded command. Hard ceiling (PORTAL_MAX_TIMEOUT, default 300s): a request above it is refused — use remote_job for a long task. ⚠️ By convention, write operations should target /tmp/ on the remote unless the user approved another path (not enforced here). |
| remote_exec | 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 ★ sudo: to run a command as root, call this with use_sudo=True — NEVER put a
bare ★ credentials: when a command needs a secret (API token, deploy key, …), do
NOT have the user paste it into the chat. They run
Targets (pick one): host="web01" | host=["web01","web02"] | group_tag="prod" (all registered hosts carrying that tag). Commands (pick one): command="uptime" — ONE command per call is the DEFAULT
and preferred shape. Run it, read the real output, check it against what you
expected (an exit-0 step can still be wrong), THEN decide the next call —
that keeps you in the loop and able to course-correct. commands=["a","b"]
batches a sequence into a SINGLE call (each with its own exit code; stops at
the first failure when stop_on_error=True, and with use_sudo each entry runs
as its own sudo command) — reach for it ONLY for a fixed, independent
sequence you do NOT need to inspect between steps. Don't bury a long or
branching pipeline in one 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, REQUIRED — no default; consciously choose a cut-off): 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 — pass a SMALL one for an exploratory / re-runnable command (e.g. 10–30) to fail fast, raising it only for a genuinely slow but bounded command. Hard ceiling (PORTAL_MAX_TIMEOUT, default 300s): a request above it is REFUSED — background a long task with remote_job instead of pinning it on a blocking call. login (default on): run the command in a LOGIN shell (bash -lc) so the
remote user's ~/.profile / ~/.bashrc PATH + env (conda / nvm / pyenv,
~/.local/bin, …) is loaded; pass login=False for a bare non-login shell.
Silently degrades to a plain exec on a host without bash. Affects only this
plain path (not sudo / secrets); operators set the default via
PORTAL_LOGIN_SHELL or a per-host use_sudo: run via 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. May be combined with use_sudo (values are read+exported inside the sudo'd shell, so sudo's env_reset can't strip them). 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. |
| local_execA | Run a command on the MCP SERVER's OWN machine (local), NOT over SSH — for anything on a remote host use remote_exec instead. This departs from the project's core goal (driving remote hosts as if local); it's a useful but off-target derivative, so it is OFF by default: the operator must explicitly 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 ★ use_sudo: run the command under sudo on THIS machine (local_exec). The
sudo password is resolved out-of-band (NEVER an argument): the per-user
credential agent populated by timeout (seconds, REQUIRED — no default): held open until the command exits or timeout elapses. Keepalive pings stop the client aborting a hung call, so pass a SMALL one for an exploratory / re-runnable command (e.g. 10–30) to fail fast, raising it only for a genuinely slow but bounded command. Hard ceiling (PORTAL_MAX_TIMEOUT, default 300s): a request above it is refused — local_exec has no background runner, so split the work into shorter steps. ★ 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. |
| remote_closeA | Close the cached persistent bash session for (the next remote_shell call reopens a fresh one). Rarely needed: the session is created/reused/auto-recreated implicitly by remote_shell — you don't manage its lifecycle. Use this only to reset a session whose state has gotten dirty. |
| remote_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 remote_exec (it waits and returns the result). Actions
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 Manual fallback (no remote_job): you can always background a command yourself with remote_exec(command="nohup mycmd >/tmp/x.log 2>&1 & echo $!") and poll the log with remote_exec(command="tail /tmp/x.log"). |
Prompts
Interactive templates invoked by user choice
| Name | Description |
|---|---|
No prompts | |
Resources
Contextual data attached and managed by the client
| Name | Description |
|---|---|
No resources | |
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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