{"id":"9a8d8f0c-f57c-43fc-acad-b52e4e04f9e8","slug":"homelab-ops-mcp","kind":"mcp","name":"homelab-ops-mcp","summary":"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,","owner":"TadMSTR","repoUrl":"https://github.com/TadMSTR/homelab-ops-mcp","license":"MIT","licenseTier":"permissive","trust":"community","stars":1,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-03-17T20:47:43.000Z","addedAt":"2026-10-21T08:32:00.802Z","logoUrl":"https://avatars.githubusercontent.com/u/69825253?v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["files-documents","cloud-infra"],"tags":[],"runtime":"python","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-25T20:25:17.586Z","sample":false,"url":"https://conduits.dev/tools/homelab-ops-mcp","language":"Python","transports":["stdio"],"install":{"pypi":"homelab-ops-mcp"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# homelab-ops-mcp\n\n[![Built with Claude Code](https://img.shields.io/badge/Built_with-Claude_Code-6B57FF?logo=claude&logoColor=white)](https://claude.ai/code)\n[![CI](https://github.com/TadMSTR/homelab-ops-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/TadMSTR/homelab-ops-mcp/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n\nA [FastMCP](https://github.com/jlowin/fastmcp) server that provides shell command execution, file system access, and process inspection over the [MCP Streamable-HTTP transport](https://modelcontextprotocol.io/specification/). Built for homelab operations agents that need to read files, run commands, and inspect processes on the host machine.\n\n## Security Warning\n\nThis 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.\n\n## Tools\n\n| Tool | Description | Key parameters | Returns |\n|------|-------------|----------------|---------|\n| `run_command` | Execute 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](#output-limits)) |\n| `read_file` | Read a file, optionally a line range | `path`, `start_line`, `end_line` | `path`, `content`, `total_lines` (`null` if the read stopped early), `truncated`; `returned_lines` when ranged or truncated |\n| `read_multiple_files` | Read several files in one call; one failure does not fail the rest | `paths` | `count`, `ok`, `failed`, `files` (a `read_file` result or an `error` per path) |\n| `write_file` | Write/overwrite a file atomically | `path`, `content`, `create_dirs` (default `true`) | `path`, `bytes_written`; `written_to` when the write went through a symlink |\n| `edit_file` | Find-and-replace edit — `old_str` must match exactly once | `path`, `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 |\n| `read_directory` | List directory contents | `path`, `recursive` (default `false`), `max_depth` (default 2) | `path`, `count`, `entries` (`name`, `type`, `path`, `size_bytes`, `children`) |\n| `list_processes` | List running processes | `sort_by` (`cpu`/`mem`/`pid`, default `cpu`), `limit` (default 30), `name_filter` | `count`, `sort_by`, `processes` (`pid`, `name`, `cpu_percent`, `mem_percent`, `status`) |\n\nPaths must be absolute; `~` is not expanded. A tool that fails returns `{\"error\": \"...\"}`\nrather than raising. `read_directory` reports `type` as `dir` or `file` only, so a symlink\nto a file, a socket or a FIFO shows as `file`. `list_processes` measures CPU since its\nprevious call, so the first call after the server starts reports `0.0` for every process.\n\n## Tool annotations\n\nEvery tool publishes MCP annotations, so a client can tell a read from a write without\nmaintaining a list of tool names:\n\n| Tool | readOnlyHint | idempotentHint | destructiveHint | openWorldHint |\n|---|---|---|---|---|\n| `read_file` | true | – | – | false |\n| `read_multiple_files` | true | – | – | false |\n| `read_directory` | true | – | – | false |\n| `list_processes` | true | – | – | false |\n| `write_file` | false | true | true | false |\n| `edit_file` | false | false | true | false |\n| `run_command` | false | false | true | **true** |\n\n`run_command` is the only `openWorldHint: true` tool — it runs arbitrary shell, so the\nnetwork is reachable through it. The read-only tools omit the two write hints, which the\nspec treats as meaningless when `readOnlyHint` is true.\n\n## Transport\n\nStreamable-HTTP on `http://<host>:<port>/mcp` (default `127.0.0.1:8282`).\n\nThe bind host defaults to `127.0.0.1` (loopback only). Because this server offers\n**unauthenticated** shell execution and filesystem access, network isolation is its\ncontrol — keep it loopback-bound unless you have a specific reason to expose it.\n\n### Host and Origin check\n\nLoopback alone does not keep a browser out. A web page whose hostname an attacker points\nat `127.0.0.1` (DNS rebinding) is, to the browser, same-origin with this port. The server\ntherefore refuses any request whose `Host` header is not a loopback name, with `421`, and\nany request that carries an `Origin` header at all, with `403`:\n\n| Accepted `Host` | Accepted `Origin` |\n|---|---|\n| `127.0.0.1:*`, `localhost:*`, `[::1]:*` | none: only browsers send `Origin`, and no browser is a client |\n\nAny port is accepted, not only the bind port: a client that reaches the server through a\nport forward (an SSH tunnel, say) sends the forward's local port. MCP clients send no\n`Origin` and are unaffected by that half. Loopback origins are refused too: allowing them\nwould trust every web app served on the same host.\n\nIf you bind `--host 0.0.0.0`, the clients arrive under another name and are refused until\nyou list it:\n\n| Variable | Default | Purpose |\n|----------|---------|---------|\n| `SYSTEM_OPS_ALLOWED_HOSTS` | _(unset)_ | Comma-separated **exact** extra `Host` values, e.g. `host.docker.internal:8282` |\n\nIf an MCP client runs in its own container or network namespace and must reach the server\nacross it, bind `--host 0.0.0.0` explicitly — loopback isn't reachable from another\nnamespace. Do this only on a trusted, isolated network, and be aware that a container\nreaching the host via `host.docker.internal` bypasses any SSRF guard that blocks private\nIP ranges.\n\n## Environment variables\n\nNone is required: every variable has a safe default, and the server runs with none set.\nEach one is described in the section linked.\n\n| Variable | Required | Default | Purpose |\n|----------|----------|---------|---------|\n| `SYSTEM_OPS_ALLOWED_HOSTS` | no | _(unset)_ | Extra accepted `Host` values ([Host and Origin check](#host-and-origin-check)) |\n| `SYSTEM_OPS_OUTPUT_LIMIT_BYTES` | no | `1048576` | `run_command` per-stream cap and the `read_file` budget ([Output limits](#output-limits)) |\n| `SYSTEM_OPS_CHILD_ENV_ENFORCE` | no | `false` | Restrict `run_command` children to the allowlist ([Child process environment](#child-process-environment)) |\n| `SYSTEM_OPS_CHILD_ENV_ALLOWLIST` | no | _(unset)_ | Extra variable names children may inherit |\n| `LOG_LEVEL` | no | `INFO` | Log level ([Logging](#logging)) |\n| `LOG_FILE` | no | _(unset)_ | Append logs here instead of stderr |\n| `OTEL_EXPORTER_OTLP_ENDPOINT` | no | _(unset)_ | OTLP gRPC collector ([Telemetry](#telemetry)) |\n| `OTEL_SERVICE_NAME` | no | `homelab-ops-mcp` | Service name reported to the collector |\n| `SYSTEM_OPS_INFLUXDB3_URL` / `_TOKEN` / `_DATABASE` | no | _(unset)_ / _(empty)_ / `homelab_ops_mcp` | InfluxDB 3 metrics sink |\n| `SYSTEM_OPS_NATS_URL` / `_SUBJECT` | no | _(unset)_ / `system.ops.mcp.metrics` | NATS metrics sink |\n\nBind host, port and path are command-line flags, not environment variables (see\n[Running](#running)).\n\n## Installation\n\n```bash\nuv sync --frozen --no-dev --no-editable          # a deployment: exactly the audited uv.lock\nuv sync --frozen --no-dev --no-editable --extra telemetry   # ... with telemetry\npip install .                                     # ad hoc: re-resolves, so NOT the lock\n```\n\nRequires Python 3.11 or later, and [uv](https://docs.astral.sh/uv/) for the locked install.\nThis installs the `homelab_ops_mcp` package and a `homelab-ops-mcp` console script.\nCI tests and audits the versions in `uv.lock`; `pip install .` resolves the ranges in\n`pyproject.toml` afresh and can install something CI never saw.\n\n## Running\n\n```bash\nhomelab-ops-mcp                          # default host 127.0.0.1 (loopback), port 8282\nhomelab-ops-mcp --port 9090              # custom port\nhomelab-ops-mcp --host 0.0.0.0           # expose to containers/LAN (unauthenticated — use with care)\nhomelab-ops-mcp --path /mcp              # HTTP path (default /mcp)\n```\n\nA backward-compatible `python server.py --host … --port …` shim is retained for\nexisting deployments. It imports the installed package, so it works with an editable or\na non-editable install.\n\nWith PM2, run the venv's console script directly (`--interpreter none`, since it is not a\nNode.js script):\n```bash\npm2 start /path/to/homelab-ops-mcp/.venv/bin/homelab-ops-mcp --name homelab-ops-mcp \\\n  --interpreter none -- --host 127.0.0.1 --port 8282\npm2 save\n```\n\nThe repo's `ecosystem.config.js` is a worked example. It starts the server through\n`run-hardened.sh`, which sets `ulimit -c 0` (no core dumps on a crash, since PM2's fork\nmode has no ulimit option) and then execs the venv's Python. Adjust its paths to your\ncheckout. Every setting has a safe default, so its `env` block declares only what that\ndeployment sets differently.\n\n### Behind scoped-mcp\n\nTo give agents per-agent, filtered access, proxy this server through\n[scoped-mcp](https://github.com/TadMSTR/scoped-mcp) as an `mcp_proxy` module. The proxy\nconnects as `localhost:8282`, which the [Host check](#host-and-origin-check) accepts.\n`tool_denylist` hides tools from one agent, for example a reviewer that should not edit:\n\n```yaml\nmodules:\n  system-ops:\n    type: mcp_proxy\n    config:\n      url: http://localhost:8282/mcp\n      tool_denylist:\n        - edit_file\n```\n\nTools are exposed with the module name as prefix (`system-ops_run_command`).\n\n### Telemetry\n\nOff by default and entirely optional. The base install carries no telemetry libraries at\nall, and every backend import is lazy and guarded, so an unset variable or a missing\npackage degrades to a no-op rather than an error.\n\n```bash\nuv sync --frozen --no-dev --no-editable --extra telemetry   # locked, as in Installation\n```\n\n| Variable | Default | Purpose |\n|----------|---------|---------|\n| `OTEL_EXPORTER_OTLP_ENDPOINT` | _(unset)_ | OTLP gRPC collector for traces and metrics |\n| `OTEL_SERVICE_NAME` | `homelab-ops-mcp` | Service name reported to the collector |\n| `SYSTEM_OPS_INFLUXDB3_URL` | _(unset)_ | InfluxDB 3 write endpoint |\n| `SYSTEM_OPS_INFLUXDB3_TOKEN` | _(empty)_ | InfluxDB 3 token |\n| `SYSTEM_OPS_INFLUXDB3_DATABASE` | `homelab_ops_mcp` | InfluxDB 3 database |\n| `SYSTEM_OPS_NATS_URL` | _(unset)_ | NATS server for metric events |\n| `SYSTEM_OPS_NATS_SUBJECT` | `system.ops.mcp.metrics` | NATS subject |\n\nPer tool: call count, error count, and latency. A tool that returns `{\"error\": ...}` counts\nas an error — these tools report failure by returning rather than raising, so counting\nexceptions alone would show a 0% error rate for a tool failing every call. A non-zero\n`exit_code` from `run_command` is *not* an error: the command failed, the tool worked.\n\nThe error label is a fixed `tool_error`, never the message. The messages embed paths and\ncommand text, which is unbounded cardinality for a metrics backend and content that should\nnot leave the host.\n\nThe tools here are synchronous, so the two network sinks run on a background event loop\nthis module owns. It is started lazily and only if InfluxDB or NATS is configured — with\nboth unset, no thread is created.\n\n**Verifying it works.** A clean `force_flush()` return is not evidence: it returns `True`\nagainst an unreachable collector too. What distinguishes them is the exporter's own\nrecords — a wrong endpoint logs `Failed to export traces …`. Check for the absence of\nthose, or better, check the service appears by name in your backend.\n\n### Output limits\n\n`run_command` captures at most `SYSTEM_OPS_OUTPUT_LIMIT_BYTES` (default 1 MiB) from\nstdout and from stderr, counted separately. If either stream reaches the cap the child's\nprocess group is killed, the captured output carries an explicit `[truncated: …]` marker,\nand the response sets `truncated: true`.\n\n| Variable | Default | Purpose |\n|----------|---------|---------|\n| `SYSTEM_OPS_OUTPUT_LIMIT_BYTES` | `1048576` | Per-stream capture cap, in bytes |\n\n`truncated` is present on every `run_command` response, so a capped result is\ndistinguishable from a genuinely short one. Output is read incrementally rather than\nbuffered to EOF, so a command producing gigabytes is stopped rather than held in memory\nfirst.\n\nThe child runs in its own session and is killed by process group, so the whole pipeline\ngoes rather than just the `bash` that spawned it. The timeout path gets the same\ntreatment, and the timeout bounds the command's exit, not only its output: a command that\nredirects its own stdio (`exec >>\"$LOG\" 2>&1`) is still killed at the deadline.\n\nEvery `run_command` response has the same keys: `stdout`, `stderr`, `exit_code`,\n`truncated`, `execution_time` and `error`. `error` is `null` unless the tool itself failed\n(a refused `cwd`, or a command that could not be started); a non-zero exit code or a\ntimeout is the command's outcome and leaves it `null`.\n\n### File tools\n\n| Behaviour | Detail |\n|---|---|\n| Read cap | `read_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. |\n| Line ranges | `start_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`. |\n| Byte fidelity | `read_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. |\n| Symlinks | `write_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. |\n| Hard links | A 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. |\n| What `edit_file` reads | Regular 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. |\n| No-match hint | When `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. |\n\n### Child process environment\n\n`run_command` shells out via `bash -c`. By default the child inherits the server's\nenvironment, minus the PM2 IPC variables. That default is being narrowed to an explicit\nallowlist, which is safer for any deployment whose server process carries configuration\nthe commands it runs have no need for.\n\nThe change ships in two steps so the effect can be measured before it is switched on.\nOut of the box the server runs in **shadow mode**: behaviour is unchanged, but every\n`run_command` log record carries `env_withheld_count` — how many variables enforcement\n*would* remove.\n\nThat count on its own describes the server's environment rather than any particular\ncommand, so it is the same on every call. The signal that tells you whether enforcement\nwould actually break a caller is `run_command.env_referenced_withheld`, logged only when a\ncommand references a variable that enforcement would take away:\n\n```json\n{\"event\": \"run_command.env_referenced_withheld\", \"names\": [\"MY_API_BASE\"], \"enforced\": false}\n```\n\nRun shadow mode across a representative workload, collect the `names` from those records,\nand they are your `SYSTEM_OPS_CHILD_ENV_ALLOWLIST`. Variable *names* are reported here\nbecause they come from the command text the caller wrote; values are never read.\n\nThe detection is deliberately approximate and errs toward over-reporting — a `$VAR` inside\nsingle quotes is counted even though the shell would not expand it, and an indirect read\nsuch as `env | grep FOO` is missed.\n\n| Variable | Default | Purpose |\n|----------|---------|---------|\n| `SYSTEM_OPS_CHILD_ENV_ENFORCE` | `false` | `true` restricts the child to the allowlist. `false` reports the count without acting on it. |\n| `SYSTEM_OPS_CHILD_ENV_ALLOWLIST` | _(unset)_ | Comma-separated **exact** variable names to add to the base allowlist. |\n\nThe base allowlist is `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `TERM`, `LANG`, `TZ`,\nand the POSIX `LC_*` locale variables.\n\n`SYSTEM_OPS_CHILD_ENV_ALLOWLIST` takes exact names only — glob patterns are refused and\nlogged rather than matched. A pattern would silently admit every future variable that\nhappens to share its prefix, which defeats the point of naming things explicitly.\n\nCommands that read configuration from disk are unaffected: a\n`source ./creds.env && …` runs the `source` *inside* the child shell, so the file is read\nafter the environment is set. What stops working under enforcement is a command relying on\na variable being ambient without sourcing it.\n\nThe PM2 IPC variables (`NODE_CHANNEL_FD`, `NODE_CHANNEL_SERIALIZATION_MODE`,\n`NODE_UNIQUE_ID`) are removed in both modes and cannot be re-added through the allowlist —\na Node.js child that inherits them aborts during teardown.\n\n### Logging\n\nStructured JSON logs go to stderr by default. Tune via environment variables:\n\n| Variable | Default | Purpose |\n|----------|---------|---------|\n| `LOG_LEVEL` | `INFO` | `DEBUG`/`INFO`/`WARNING`/`ERROR` |\n| `LOG_FILE` | _(unset)_ | Append logs to this path instead of stderr |\n\n`run_command.done`, `.timeout` and `.error` records carry `env_withheld_count`,\n`env_enforced` and `verb_class`. For the environment itself only the count is recorded —\nnever a variable name, never a value. The separate `run_command.env_referenced_withheld`\nrecord does carry names, but only ones the caller wrote into the command text.\n\nCommand text and file contents are logged at `DEBUG` only; `INFO` records carry\nnon-sensitive metadata (paths, cwd, exit codes).\n\n`verb_class` says whether the command was a version-control operation, without saying\nwhat it was:\n\n| Value | Meaning |\n|-------|---------|\n| `git_write` | a `git` subcommand that changes local or remote state |\n| `gh_write` | a `gh` subcommand that changes state, including `gh api` with a non-GET method |\n| `git_read` | a read-only `git` subcommand |\n| `gh_read` | a read-only `gh` subcommand, including a plain `gh api` GET |\n| `vcs_other` | a `git`/`gh` invocation whose subcommand is not classified |\n| `unclassified` | the command was too long to parse (see below) — *not* a claim that it was harmless |\n| `other` | not `git` or `gh` at all |\n\nIt is derived from the parsed command at the same point the `DEBUG` record already has\nit. A compound command takes the class of its most significant segment, so\n`git status && git push` is `git_write`.\n\nIt sees through the ways one command hides inside another:\n\n| Form | Example |\n|---|---|\n| wrappers and `sudo` | `sudo git reset --hard` |\n| absolute paths | `/usr/bin/git push` |\n| leading assignments | `GIT_DIR=/a git -C /b push` |\n| pipelines and `&&`/`;` chains | `git status && git push` |\n| newlines | `cd /r`⏎`git push` |\n| command substitution | `echo $(git push)`, `` x=`git push` `` |\n| shell `-c` payloads | `bash -c \"git push\"` |\n| positional wrapper args | `timeout 30 git push`, `ssh host git push` |\n| command-bearing flags | `find . -exec git push {} \\;` |\n| nested git execution | `git submodule foreach 'git push'` |\n\nWhere a subcommand is ambiguous the classification resolves toward `write`:\nunder-reporting a write would hide exactly what the field exists to show. That includes\nsubcommands whose bare form reads harmless but is not — `git stash` *is* `git stash push`,\n`git reflog expire` destroys history, `git bisect start` moves `HEAD`.\n\n`vcs_other` is deliberately distinct from `other` — a `git` subcommand nobody has\nclassified is a very different fact from `ls`, and a rising `vcs_other` is the signal the\ntable needs a new entry.\n\n`unclassified` is distinct from both. Commands longer than `MAX_CLASSIFY_BYTES` (64 KiB)\nare parsed only up to that bound, because `shlex` is quadratic on one long unbroken token\nand classification runs *before* `run_command`'s own `timeout` can apply — so an oversized\ncommand would otherwise hang the call at the classification step regardless of `timeout`.\nIf a write is found in the parsed head it is still reported; if nothing is found the answer\nis `unclassified`, never `other`. `other` is a positive claim that this was not a repo\noperation, and a parse that was declined cannot support it.\n\nEverything on the stream is JSON, including records from uvicorn, fastmcp and the MCP SDK\n— they are routed through the same processor chain rather than writing plain text\nalongside it. Each record carries a `logger` field naming its source, so this server's own\nevents and a library's are distinguishable.\n\nuvicorn's per-request access log is off. It was one line per request with the request\nvolume being entirely MCP tool traffic, and it said nothing the per-tool events do not.\nuvicorn *errors* still log.\n\nThere is no rotation built in — point `LOG_FILE` at a path your log rotation already\ncovers. The handle is opened in append mode, so a `copytruncate`-style rotation is safe.\n\n## Development\n\n```bash\nuv sync --extra dev            # installs exactly uv.lock\nuv run ruff check . && uv run ruff format --check .\nuv run pytest --cov=homelab_ops_mcp --cov-report=term-missing\n```\n\nDependencies are locked in `uv.lock`, and CI installs from it, so a green run says\nsomething about the versions that are deployed. CI runs the suite across Python 3.11–3.13,\nonce more with the `[telemetry]` extra installed, and once against the newest versions\n`pyproject.toml` allows. It audits the locked set, not a fresh resolve.\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow and [ARCHITECTURE.md](ARCHITECTURE.md)\nfor the layout.\n\n## MCP Client Configuration\n\n### LibreChat (`librechat.yaml`)\n\n```yaml\nmcpServers:\n  homelab-ops:\n    type: streamable-http\n    url: http://127.0.0.1:8282/mcp\n```\n\nIf LibreChat runs in a container (so the server is on a different host/namespace), use\n`http://host.docker.internal:8282/mcp` and start the server with `--host 0.0.0.0` — see the\nTransport section for the security caveats.\n\n### Claude Code (project `.mcp.json`)\n\nOr `claude mcp add --transport http homelab-ops http://localhost:8282/mcp`.\n\n```json\n{\n  \"mcpServers\": {\n    \"homelab-ops\": {\n      \"type\": \"http\",\n      \"url\": \"http://localhost:8282/mcp\"\n    }\n  }\n}\n```\n\n### Claude Desktop (`claude_desktop_config.json`)\n\nClaude Desktop can connect via streamable-http:\n\n```json\n{\n  \"mcpServers\": {\n    \"homelab-ops\": {\n      \"url\": \"http://localhost:8282/mcp\"\n    }\n  }\n}\n```\n\n## Default Working Directory\n\n`run_command` defaults the working directory to the current user's home directory (`Path.home()`). Override per-call with the `cwd` parameter.\n\n## License\n\nMIT\n"}