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
hosts

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: hosts(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 remote_exec's group_tag). Example: hosts(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: hosts(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.

remote_transfer

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

Modes

  • direction="upload": local_path → remote_path (single file). Example: remote_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: remote_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: remote_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: remote_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). resume: (upload only) if a SMALLER partial already exists at remote_path from an interrupted transfer, append just the missing tail instead of restarting, then verify the whole file's sha256 and re-upload fresh once on mismatch. Default on; set False to force a fresh overwrite. The result carries resumed=true when the tail path ran.

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 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: remote_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: remote_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: remote_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: remote_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 inspect.

policy_checkA

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

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

  • command="rm -rf /" : check whether this command would be allowed on this host. Example: policy_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 policy_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 inspect(view="policy") to inspect what the server actually has loaded. ALLOWED therefore means "no rule currently blocks this", not "this is safe to run".

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

  • 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 remote_shell reuses per host). This is plumbing diagnostics — the sessions are implicit, which is why they live in inspect rather than carrying their own list like tunnels/hosts.

  • view="history": last limit audit log entries (default 50). Optional host_filter. Example: inspect(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.

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 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 remote_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: remote_read(host, path, start=40, end=80) (still paged if that range is huge). * Just the first N lines: remote_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) use_sudo: read via sudo cat (root) for a file the SSH user can't read (root-owned / mode 600). The sudo password is resolved out-of-band per host (never a parameter) and the result is flagged high_risk. Exact bytes are preserved (no newline stripping) so file_hash stays valid for remote_patch.

remote_patch

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

Workflow:

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

  2. Call remote_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.

  • use_sudo: read+write the file as root, for a root-owned file the SSH user can't touch. Keeps the SAME hash safety + atomicity as the normal path: content is read via sudo cat, staged over SFTP into the user's private home, then placed with one sudo step (cp → temp beside the target → restore owner/group/mode → atomic rename). The target must already exist; the sudo password is resolved out-of-band per host and the result is flagged high_risk. (Implementation note in the README.)

remote_grep

Search file contents with a regex on a remote host (ripgrep, fallback grep). Prefer this over running raw rg/grep through remote_exec — it returns structured JSON and caps output so a broad search can't blow up your context. Pair it with remote_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.

remote_glob

Find files by a glob pattern on a remote host, newest first. Prefer this over running raw find/ls through remote_exec — it returns structured JSON, sorts by modification time, and hard-caps at 100 files. Use remote_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 remote_grep this does NOT respect .gitignore (matches CC Glob).

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:

  • command="pytest" → {host, session_id, command, exit_code, output, duration_s}. ONE command per call is the preferred shape: the session PERSISTS across separate calls, so cwd / exports / venv from an earlier call still apply to the next — you keep step-by-step continuity AND get to read each step's real output before deciding the next.

  • commands=["cd /app","source .venv/bin/activate","pytest"] → runs the sequence in order in ONE call (same session). Reach for it ONLY for a fixed sequence you do NOT need to inspect between steps; you can't judge one step's output before the next runs. 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 remote_exec).

  • Output is the COMBINED stdout+stderr stream (use remote_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 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 ssh user@host …; it reuses a warm connection pool and fans out across hosts. Stateless: need cwd/exports to persist → remote_shell; a long task to background and poll → remote_job.

★ sudo: to run a command as root, call this with use_sudo=True — NEVER put a bare sudo … in the command or in remote_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 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" — 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 a && b && c string: you lose per-step visibility and can't intervene. A multi-line command string runs as one bash script.

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 login_shell: in hosts.yaml.

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). May be combined with secrets (their values ride the same stdin channel, after the password).

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 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.

★ 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 portal sudo set-local, or a top-level <local>: section's sudo_password_command in hosts.yaml. Fed to sudo -S -k on stdin (never on argv/audit). May be combined with secrets (their values ride the same stdin channel, read+exported inside the sudo'd shell). This is the LOCAL counterpart of remote_exec(use_sudo=True); the reserved identity <local> is distinct from any SSH host named local / localhost.

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

  • action="submit": start command on host in the background — in a LOGIN shell (bash -lc, loading ~/.profile/.bashrc PATH+env; login=False to opt out) via 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 remote_exec (one-shot) or remote_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 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

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