Skip to content
Conduit

mcp server

homelab-ops-mcp

A FastMCP server that provides shell command execution, file system access, and process inspection over the MCP Streamable-HTTP transport. Built for homelab operations agents that need to read files,

TadMSTR/homelab-ops-mcpcommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

Before you start

  • uv (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "homelab-ops-mcp": {
      "command": "uvx",
      "args": [
        "homelab-ops-mcp"
      ]
    }
  }
}

same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12

Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs

README

synced Oct 25, 2026

homelab-ops-mcp

Built with Claude Code CI License: MIT

A FastMCP server that provides shell command execution, file system access, and process inspection over the MCP Streamable-HTTP transport. Built for homelab operations agents that need to read files, run commands, and inspect processes on the host machine.

Security Warning

This server provides unrestricted shell access to the host machine. Any MCP client with a connection can run arbitrary commands, read any file the process user can access, and write to any writable path. Deploy it only behind a trusted network boundary and on services you control. It is not designed for multi-user or internet-exposed environments.

Tools

ToolDescriptionKey parametersReturns
run_commandExecute a shell command (bash -c)command, cwd (default: home dir), timeout (seconds, default 30)stdout, stderr, exit_code, truncated, execution_time, error (see Output limits)
read_fileRead a file, optionally a line rangepath, start_line, end_linepath, content, total_lines (null if the read stopped early), truncated; returned_lines when ranged or truncated
read_multiple_filesRead several files in one call; one failure does not fail the restpathscount, ok, failed, files (a read_file result or an error per path)
write_fileWrite/overwrite a file atomicallypath, content, create_dirs (default true)path, bytes_written; written_to when the write went through a symlink
edit_fileFind-and-replace edit — old_str must match exactly oncepath, old_str, new_str, dry_run (default false)path, status, matches_replaced, dry_run; bytes_before/bytes_after on a dry run; line_endings, written_to when they apply. No match: error plus the closest passage
read_directoryList directory contentspath, recursive (default false), max_depth (default 2)path, count, entries (name, type, path, size_bytes, children)
list_processesList running processessort_by (cpu/mem/pid, default cpu), limit (default 30), name_filtercount, sort_by, processes (pid, name, cpu_percent, mem_percent, status)

Paths must be absolute; ~ is not expanded. A tool that fails returns {"error": "..."} rather than raising. read_directory reports type as dir or file only, so a symlink to a file, a socket or a FIFO shows as file. list_processes measures CPU since its previous call, so the first call after the server starts reports 0.0 for every process.

Tool annotations

Every tool publishes MCP annotations, so a client can tell a read from a write without maintaining a list of tool names:

ToolreadOnlyHintidempotentHintdestructiveHintopenWorldHint
read_filetrue––false
read_multiple_filestrue––false
read_directorytrue––false
list_processestrue––false
write_filefalsetruetruefalse
edit_filefalsefalsetruefalse
run_commandfalsefalsetruetrue

run_command is the only openWorldHint: true tool — it runs arbitrary shell, so the network is reachable through it. The read-only tools omit the two write hints, which the spec treats as meaningless when readOnlyHint is true.

Transport

Streamable-HTTP on http://<host>:<port>/mcp (default 127.0.0.1:8282).

The bind host defaults to 127.0.0.1 (loopback only). Because this server offers unauthenticated shell execution and filesystem access, network isolation is its control — keep it loopback-bound unless you have a specific reason to expose it.

Host and Origin check

Loopback alone does not keep a browser out. A web page whose hostname an attacker points at 127.0.0.1 (DNS rebinding) is, to the browser, same-origin with this port. The server therefore refuses any request whose Host header is not a loopback name, with 421, and any request that carries an Origin header at all, with 403:

Accepted HostAccepted Origin
127.0.0.1:*, localhost:*, [::1]:*none: only browsers send Origin, and no browser is a client

Any port is accepted, not only the bind port: a client that reaches the server through a port forward (an SSH tunnel, say) sends the forward's local port. MCP clients send no Origin and are unaffected by that half. Loopback origins are refused too: allowing them would trust every web app served on the same host.

If you bind --host 0.0.0.0, the clients arrive under another name and are refused until you list it:

VariableDefaultPurpose
SYSTEM_OPS_ALLOWED_HOSTS(unset)Comma-separated exact extra Host values, e.g. host.docker.internal:8282

If an MCP client runs in its own container or network namespace and must reach the server across it, bind --host 0.0.0.0 explicitly — loopback isn't reachable from another namespace. Do this only on a trusted, isolated network, and be aware that a container reaching the host via host.docker.internal bypasses any SSRF guard that blocks private IP ranges.

Environment variables

None is required: every variable has a safe default, and the server runs with none set. Each one is described in the section linked.

VariableRequiredDefaultPurpose
SYSTEM_OPS_ALLOWED_HOSTSno(unset)Extra accepted Host values (Host and Origin check)
SYSTEM_OPS_OUTPUT_LIMIT_BYTESno1048576run_command per-stream cap and the read_file budget (Output limits)
SYSTEM_OPS_CHILD_ENV_ENFORCEnofalseRestrict run_command children to the allowlist (Child process environment)
SYSTEM_OPS_CHILD_ENV_ALLOWLISTno(unset)Extra variable names children may inherit
LOG_LEVELnoINFOLog level (Logging)
LOG_FILEno(unset)Append logs here instead of stderr
OTEL_EXPORTER_OTLP_ENDPOINTno(unset)OTLP gRPC collector (Telemetry)
OTEL_SERVICE_NAMEnohomelab-ops-mcpService name reported to the collector
SYSTEM_OPS_INFLUXDB3_URL / _TOKEN / _DATABASEno(unset) / (empty) / homelab_ops_mcpInfluxDB 3 metrics sink
SYSTEM_OPS_NATS_URL / _SUBJECTno(unset) / system.ops.mcp.metricsNATS metrics sink

Bind host, port and path are command-line flags, not environment variables (see Running).

Installation

uv sync --frozen --no-dev --no-editable          # a deployment: exactly the audited uv.lock
uv sync --frozen --no-dev --no-editable --extra telemetry   # ... with telemetry
pip install .                                     # ad hoc: re-resolves, so NOT the lock

Requires Python 3.11 or later, and uv for the locked install. This installs the homelab_ops_mcp package and a homelab-ops-mcp console script. CI tests and audits the versions in uv.lock; pip install . resolves the ranges in pyproject.toml afresh and can install something CI never saw.

Running

homelab-ops-mcp                          # default host 127.0.0.1 (loopback), port 8282
homelab-ops-mcp --port 9090              # custom port
homelab-ops-mcp --host 0.0.0.0           # expose to containers/LAN (unauthenticated — use with care)
homelab-ops-mcp --path /mcp              # HTTP path (default /mcp)

A backward-compatible python server.py --host … --port … shim is retained for existing deployments. It imports the installed package, so it works with an editable or a non-editable install.

With PM2, run the venv's console script directly (--interpreter none, since it is not a Node.js script):

pm2 start /path/to/homelab-ops-mcp/.venv/bin/homelab-ops-mcp --name homelab-ops-mcp \
  --interpreter none -- --host 127.0.0.1 --port 8282
pm2 save

The repo's ecosystem.config.js is a worked example. It starts the server through run-hardened.sh, which sets ulimit -c 0 (no core dumps on a crash, since PM2's fork mode has no ulimit option) and then execs the venv's Python. Adjust its paths to your checkout. Every setting has a safe default, so its env block declares only what that deployment sets differently.

Behind scoped-mcp

To give agents per-agent, filtered access, proxy this server through scoped-mcp as an mcp_proxy module. The proxy connects as localhost:8282, which the Host check accepts. tool_denylist hides tools from one agent, for example a reviewer that should not edit:

modules:
  system-ops:
    type: mcp_proxy
    config:
      url: http://localhost:8282/mcp
      tool_denylist:
        - edit_file

Tools are exposed with the module name as prefix (system-ops_run_command).

Telemetry

Off by default and entirely optional. The base install carries no telemetry libraries at all, and every backend import is lazy and guarded, so an unset variable or a missing package degrades to a no-op rather than an error.

uv sync --frozen --no-dev --no-editable --extra telemetry   # locked, as in Installation
VariableDefaultPurpose
OTEL_EXPORTER_OTLP_ENDPOINT(unset)OTLP gRPC collector for traces and metrics
OTEL_SERVICE_NAMEhomelab-ops-mcpService name reported to the collector
SYSTEM_OPS_INFLUXDB3_URL(unset)InfluxDB 3 write endpoint
SYSTEM_OPS_INFLUXDB3_TOKEN(empty)InfluxDB 3 token
SYSTEM_OPS_INFLUXDB3_DATABASEhomelab_ops_mcpInfluxDB 3 database
SYSTEM_OPS_NATS_URL(unset)NATS server for metric events
SYSTEM_OPS_NATS_SUBJECTsystem.ops.mcp.metricsNATS subject

Per tool: call count, error count, and latency. A tool that returns {"error": ...} counts as an error — these tools report failure by returning rather than raising, so counting exceptions alone would show a 0% error rate for a tool failing every call. A non-zero exit_code from run_command is not an error: the command failed, the tool worked.

The error label is a fixed tool_error, never the message. The messages embed paths and command text, which is unbounded cardinality for a metrics backend and content that should not leave the host.

The tools here are synchronous, so the two network sinks run on a background event loop this module owns. It is started lazily and only if InfluxDB or NATS is configured — with both unset, no thread is created.

Verifying it works. A clean force_flush() return is not evidence: it returns True against an unreachable collector too. What distinguishes them is the exporter's own records — a wrong endpoint logs Failed to export traces …. Check for the absence of those, or better, check the service appears by name in your backend.

Output limits

run_command captures at most SYSTEM_OPS_OUTPUT_LIMIT_BYTES (default 1 MiB) from stdout and from stderr, counted separately. If either stream reaches the cap the child's process group is killed, the captured output carries an explicit [truncated: …] marker, and the response sets truncated: true.

VariableDefaultPurpose
SYSTEM_OPS_OUTPUT_LIMIT_BYTES1048576Per-stream capture cap, in bytes

truncated is present on every run_command response, so a capped result is distinguishable from a genuinely short one. Output is read incrementally rather than buffered to EOF, so a command producing gigabytes is stopped rather than held in memory first.

The child runs in its own session and is killed by process group, so the whole pipeline goes rather than just the bash that spawned it. The timeout path gets the same treatment, and the timeout bounds the command's exit, not only its output: a command that redirects its own stdio (exec >>"$LOG" 2>&1) is still killed at the deadline.

Every run_command response has the same keys: stdout, stderr, exit_code, truncated, execution_time and error. error is null unless the tool itself failed (a refused cwd, or a command that could not be started); a non-zero exit code or a timeout is the command's outcome and leaves it null.

File tools

BehaviourDetail
Read capread_file returns at most SYSTEM_OPS_OUTPUT_LIMIT_BYTES of content, counted as UTF-8 after decoding; past that truncated is true and returned_lines says where it stopped. The file is streamed and reading stops once the range or the budget is done, so a short range costs a short read; total_lines is then null, since the rest was not counted. read_multiple_files shares one budget across the whole call and counts each distinct path once.
Line rangesstart_line below 1 is read as 1. end_line below 1, or before start_line, is an error. A range starting past the end returns empty content with returned_lines: "none". Lines end at LF, as for wc -l.
Byte fidelityread_file returns content as it is on disk, CRLF included. edit_file rewrites only the matched span; every other byte is kept. In a CRLF file an old_str written with LF breaks is matched against its CRLF form and new_str is given CRLF breaks (line_endings: "crlf" in the result). A file that is not valid UTF-8 is refused by edit_file rather than re-encoded.
Symlinkswrite_file and edit_file write through a symlink: the link is kept, its target changes, and written_to names the file written. A link that another user planted in a shared sticky directory such as /tmp, anywhere in the path, is refused, as the kernel's protected_symlinks rule would. With create_dirs, a missing directory the link points into is created.
Hard linksA file with more than one hard link is refused by write_file and edit_file. Their writes are atomic (temp file and rename), and a rename would detach this name from the others.
What edit_file readsRegular files only, up to 16 MiB. A FIFO, device or directory is refused without blocking, and so is a larger file; use run_command for those.
No-match hintWhen edit_file finds no match it quotes the closest passage. That search is skipped for an old_str over 2 KiB or a file over 2 MiB, and gives up after one second, so a failed edit always returns promptly.

Child process environment

run_command shells out via bash -c. By default the child inherits the server's environment, minus the PM2 IPC variables. That default is being narrowed to an explicit allowlist, which is safer for any deployment whose server process carries configuration the commands it runs have no need for.

The change ships in two steps so the effect can be measured before it is switched on. Out of the box the server runs in shadow mode: behaviour is unchanged, but every run_command log record carries env_withheld_count — how many variables enforcement would remove.

That count on its own describes the server's environment rather than any particular command, so it is the same on every call. The signal that tells you whether enforcement would actually break a caller is run_command.env_referenced_withheld, logged only when a command references a variable that enforcement would take away:

{"event": "run_command.env_referenced_withheld", "names": ["MY_API_BASE"], "enforced": false}

Run shadow mode across a representative workload, collect the names from those records, and they are your SYSTEM_OPS_CHILD_ENV_ALLOWLIST. Variable names are reported here because they come from the command text the caller wrote; values are never read.

The detection is deliberately approximate and errs toward over-reporting — a $VAR inside single quotes is counted even though the shell would not expand it, and an indirect read such as env | grep FOO is missed.

VariableDefaultPurpose
SYSTEM_OPS_CHILD_ENV_ENFORCEfalsetrue restricts the child to the allowlist. false reports the count without acting on it.
SYSTEM_OPS_CHILD_ENV_ALLOWLIST(unset)Comma-separated exact variable names to add to the base allowlist.

The base allowlist is PATH, HOME, USER, LOGNAME, SHELL, TERM, LANG, TZ, and the POSIX LC_* locale variables.

SYSTEM_OPS_CHILD_ENV_ALLOWLIST takes exact names only — glob patterns are refused and logged rather than matched. A pattern would silently admit every future variable that happens to share its prefix, which defeats the point of naming things explicitly.

Commands that read configuration from disk are unaffected: a source ./creds.env && … runs the source inside the child shell, so the file is read after the environment is set. What stops working under enforcement is a command relying on a variable being ambient without sourcing it.

The PM2 IPC variables (NODE_CHANNEL_FD, NODE_CHANNEL_SERIALIZATION_MODE, NODE_UNIQUE_ID) are removed in both modes and cannot be re-added through the allowlist — a Node.js child that inherits them aborts during teardown.

Logging

Structured JSON logs go to stderr by default. Tune via environment variables:

VariableDefaultPurpose
LOG_LEVELINFODEBUG/INFO/WARNING/ERROR
LOG_FILE(unset)Append logs to this path instead of stderr

run_command.done, .timeout and .error records carry env_withheld_count, env_enforced and verb_class. For the environment itself only the count is recorded — never a variable name, never a value. The separate run_command.env_referenced_withheld record does carry names, but only ones the caller wrote into the command text.

Command text and file contents are logged at DEBUG only; INFO records carry non-sensitive metadata (paths, cwd, exit codes).

verb_class says whether the command was a version-control operation, without saying what it was:

ValueMeaning
git_writea git subcommand that changes local or remote state
gh_writea gh subcommand that changes state, including gh api with a non-GET method
git_reada read-only git subcommand
gh_reada read-only gh subcommand, including a plain gh api GET
vcs_othera git/gh invocation whose subcommand is not classified
unclassifiedthe command was too long to parse (see below) — not a claim that it was harmless
othernot git or gh at all

It is derived from the parsed command at the same point the DEBUG record already has it. A compound command takes the class of its most significant segment, so git status && git push is git_write.

It sees through the ways one command hides inside another:

FormExample
wrappers and sudosudo git reset --hard
absolute paths/usr/bin/git push
leading assignmentsGIT_DIR=/a git -C /b push
pipelines and &&/; chainsgit status && git push
newlinescd /r⏎git push
command substitutionecho $(git push), x=`git push`
shell -c payloadsbash -c "git push"
positional wrapper argstimeout 30 git push, ssh host git push
command-bearing flagsfind . -exec git push {} \;
nested git executiongit submodule foreach 'git push'

Where a subcommand is ambiguous the classification resolves toward write: under-reporting a write would hide exactly what the field exists to show. That includes subcommands whose bare form reads harmless but is not — git stash is git stash push, git reflog expire destroys history, git bisect start moves HEAD.

vcs_other is deliberately distinct from other — a git subcommand nobody has classified is a very different fact from ls, and a rising vcs_other is the signal the table needs a new entry.

unclassified is distinct from both. Commands longer than MAX_CLASSIFY_BYTES (64 KiB) are parsed only up to that bound, because shlex is quadratic on one long unbroken token and classification runs before run_command's own timeout can apply — so an oversized command would otherwise hang the call at the classification step regardless of timeout. If a write is found in the parsed head it is still reported; if nothing is found the answer is unclassified, never other. other is a positive claim that this was not a repo operation, and a parse that was declined cannot support it.

Everything on the stream is JSON, including records from uvicorn, fastmcp and the MCP SDK — they are routed through the same processor chain rather than writing plain text alongside it. Each record carries a logger field naming its source, so this server's own events and a library's are distinguishable.

uvicorn's per-request access log is off. It was one line per request with the request volume being entirely MCP tool traffic, and it said nothing the per-tool events do not. uvicorn errors still log.

There is no rotation built in — point LOG_FILE at a path your log rotation already covers. The handle is opened in append mode, so a copytruncate-style rotation is safe.

Development

uv sync --extra dev            # installs exactly uv.lock
uv run ruff check . && uv run ruff format --check .
uv run pytest --cov=homelab_ops_mcp --cov-report=term-missing

Dependencies are locked in uv.lock, and CI installs from it, so a green run says something about the versions that are deployed. CI runs the suite across Python 3.11–3.13, once more with the [telemetry] extra installed, and once against the newest versions pyproject.toml allows. It audits the locked set, not a fresh resolve.

See CONTRIBUTING.md for the full workflow and ARCHITECTURE.md for the layout.

MCP Client Configuration

LibreChat (librechat.yaml)

mcpServers:
  homelab-ops:
    type: streamable-http
    url: http://127.0.0.1:8282/mcp

If LibreChat runs in a container (so the server is on a different host/namespace), use http://host.docker.internal:8282/mcp and start the server with --host 0.0.0.0 — see the Transport section for the security caveats.

Claude Code (project .mcp.json)

Or claude mcp add --transport http homelab-ops http://localhost:8282/mcp.

{
  "mcpServers": {
    "homelab-ops": {
      "type": "http",
      "url": "http://localhost:8282/mcp"
    }
  }
}

Claude Desktop (claude_desktop_config.json)

Claude Desktop can connect via streamable-http:

{
  "mcpServers": {
    "homelab-ops": {
      "url": "http://localhost:8282/mcp"
    }
  }
}

Default Working Directory

run_command defaults the working directory to the current user's home directory (Path.home()). Override per-call with the cwd parameter.

License

MIT

↑↓ navigate openesc close