There comes a point in every home-lab enthusiast’s journey where the automation you’ve built stops being satisfied with telling you what’s wrong and starts wanting to fix it. That’s roughly where I’ve landed with my Matrix server stack. I already had InfiniGPT, a Matrix-native chatbot, wired up to a growing constellation of Model Context Protocol (MCP) servers giving it visibility into Nextcloud, Rancher, Pi-hole, Netdata, and half a dozen other services scattered across my homelab. What it didn’t have was a general-purpose way to reach out over SSH and actually do things on the boxes it was watching. This post covers the RemoteOps MCP server I built to close that gap, why I built it the way I did, and — because this is not a toy — a candid look at the security trade-offs I’ve made and why they’re acceptable for a home network but would be a firing offence anywhere else.

High-level summary

At its core, this piece of work adds a new, self-hosted MCP server (living in mcp-server/ inside my matrix-server repository) that exposes SSH-based remote administration as a set of tools an LLM can call. Concretely, InfiniGPT can now run arbitrary shell commands, read and write files, edit files in place, restart and inspect systemd services, and reboot machines — across every host defined in my ~/.ssh/config — using the passwordless sudo I already have configured on those boxes.

PointDescription
What it isA new Dockerised MCP server (mcp-server) running as part of the existing matrix-server Docker Compose stack.
What it exposesNine tools, all prefixed remoteops_, covering command execution, file read/write/edit, directory listing, service status/restart, and host reboot.
How it connectsRuns as an always-on service on the internal matrix Docker network, speaking MCP over streamable HTTP, and is registered into InfiniGPT’s existing MCP client configuration.
How it reaches hostsShells out to the real OpenSSH client binary, using a read-only bind mount of my actual ~/.ssh directory (config, keys, and known_hosts).
Why nowInfiniGPT already had read-heavy visibility into my infrastructure via other MCP servers; this was the natural, if riskier, next step towards genuine remediation.

Why I built it

I run a fairly sprawling homelab: a Proxmox hypervisor, a small Kubernetes cluster, a CasaOS box, a couple of physical machines on the tailnet, and various bits of infrastructure tooling. InfiniGPT already had “read” superpowers through MCP servers like PatchMon (patch status), Rancher (Kubernetes), Netdata (metrics), and Nextcloud. What it was missing was the ability to close the loop: if it can tell me a service is down, why shouldn’t it be able to restart that service? If it can tell me a host needs a config tweak, why shouldn’t it be able to make that tweak, given I’d be reviewing its working via the chat transcript anyway?

The honest answer is “because it’s a much bigger blast radius”, and I want to be upfront about that rather than glossing over it, which is exactly why a large chunk of this post — and a large chunk of the actual engineering effort — went into being explicit about the security model rather than pretending it isn’t there.

What actually is an MCP server?

For readers who haven’t run into it yet, the Model Context Protocol (MCP) is an open protocol, originally popularised by Anthropic, that standardises how large language models discover and call external “tools”. Rather than every AI application inventing its own bespoke plugin system, MCP defines a common wire format: a client (in my case, InfiniGPT) connects to one or more MCP servers, asks them “what tools do you have?”, and receives back a machine-readable schema for each tool (name, description, and a JSON Schema describing its parameters). When the LLM decides it wants to use a tool, the client sends a structured call to the relevant server, gets a structured result back, and feeds that result back into the conversation.

PointDescription
ClientThe application embedding the LLM — here, InfiniGPT, my Matrix chatbot.
ServerA separate process exposing a set of “tools” (functions) over a defined transport.
TransportHow client and server talk: commonly stdio (spawn a subprocess and talk over its stdin/stdout) or streamable HTTP (a long-running web service).
ToolA single named, schema-described function the LLM can choose to invoke, e.g. remoteops_run_command.
Why it mattersIt decouples “the model that reasons” from “the code that acts”, letting you bolt new capabilities onto an LLM without touching the LLM itself.

Crucially, MCP servers don’t have any inherent understanding of trust or intent — they simply expose capabilities and execute what they’re asked to. All of the judgement about whether something should be allowed to happen lives either in the server’s own implementation or in whatever sits between the model and the server. That distinction matters a great deal for the security discussion later on.

How it integrates with my Matrix setup, in detail

My Matrix stack is a Docker Compose project with a couple of dozen services — Synapse itself, Element, LiveKit for calls, an admin API and UI, various bridges, and InfiniGPT as the chat-based automation layer. InfiniGPT already spoke MCP to a handful of third-party servers, each wired up as an entry in its llm.mcp_servers configuration block. Adding RemoteOps meant following (and, in one case, gently correcting) that existing pattern.

PointDescription
New Compose servicemcp-server is a new, independent service in docker-compose.yml, built from docker/mcp-server/Dockerfile (Python 3.12-slim plus the OpenSSH client).
NetworkingIt joins the existing internal matrix Docker bridge network, so InfiniGPT can reach it at http://mcp-server:8800/mcp without any port being exposed to the host or the internet.
SSH accessA new environment variable, SSH_CONFIG_DIR, points at my real /home/david/.ssh directory, which is bind-mounted read-only into the container at /root/.ssh. Nothing is copied or duplicated into the repository.
Registration with InfiniGPTThe server is registered in runtime/infinigpt/config.json under llm.mcp_servers.RemoteOps as {"url": "http://mcp-server:8800/mcp"}, using InfiniGPT’s existing FastMCPClient machinery.
Tool namingEvery tool is prefixed remoteops_ specifically to avoid colliding with generically named tools from other MCP servers already configured (a couple of which also expose things like services or system).
Lifecycleinfinigpt now formally depends_on mcp-server in Compose, so the two come up together, though InfiniGPT is resilient to a slow or failed MCP server at startup (it simply logs and continues without that server’s tools).
Enable/disable/validateThree new scripts — enable_remoteops_mcp.sh, disable_remoteops_mcp.sh, and validate_remoteops_mcp.sh — follow the exact pattern already used for every other MCP integration in the repo, so operationally it looks and feels identical to the Docker, Nextcloud, or Rancher integrations.

One subtlety worth calling out, because it cost me a debugging session: InfiniGPT’s MCP client library (fastmcp) has quite specific rules about what counts as a valid server entry in its configuration. A bare URL string, e.g. "RemoteOps": "http://mcp-server:8800/mcp", is silently ignored — the library only auto-recognises an entry as a server definition if the value is a dictionary containing a command or url key. The fix was trivial once diagnosed (wrap the URL as {"url": "..."}), but it’s a good illustration of how much implicit convention sits underneath what looks like a simple JSON config file.

Security implications — and why this is a home-network-only decision

This is the part I want to be most direct about, because it would be dishonest to present this as a neutral engineering exercise. What I’ve built is, deliberately, a system where any Matrix user who can talk to my bot in one of its configured rooms can ask it, in plain English, to run root-equivalent commands, rewrite arbitrary files, or reboot any machine listed in my SSH configuration — including my Proxmox hypervisor and Kubernetes nodes.

PointDescription
No per-tool access controlInfiniGPT’s existing trust model gates a handful of slash-commands (like .tools or .model set) to admins, but ordinary chat (.ai) — and therefore every tool call it triggers — is open to anyone in a configured room. RemoteOps inherits that model unchanged.
Root-equivalent by designEvery host in scope has passwordless sudo configured, and RemoteOps deliberately uses sudo -n (non-interactive) for privileged operations, so there’s no password prompt standing in the way.
Blocklist is a net, not a wallI did add a small regex-based blocklist that rejects a handful of obviously catastrophic patterns — whole-disk dd, mkfs, fork bombs, recursive chmod/chown of / — but I want to be unambiguous that this is a best-effort safety net for typos and accidents, not a security boundary. It does not parse shell syntax and can trivially be bypassed by, say, writing a script to disk and then executing that script.
Full host exposureThe tool inventory is drawn from every concrete entry in my SSH config, with no allow-list or deny-list. This includes hosts that aren’t general-purpose servers at all, such as my Git remotes — calling a tool against those will simply fail harmlessly, but they are visible in the tool output.
No action attributionBecause InfiniGPT’s tool-calling plumbing doesn’t currently thread the invoking Matrix user’s identity down into MCP tool arguments, my audit log records what happened (tool, host, command, timestamp, exit code) but not who in Matrix asked for it. Closing that gap would mean patching InfiniGPT’s internals, which I’ve deliberately deferred as a future enhancement rather than accepting the added fragility now.
Immediate, largely irreversible actionsThe reboot tool requires an explicit confirm=true argument, but beyond that single guard there’s no further confirmation loop, no dry-run mode, and no delay before a reboot is issued.

Given all of that, why did I build it this way rather than bolting on admin-only gating, a stricter allow-list, or a confirmation workflow? Three honest reasons. First, this bot only ever runs inside my own Matrix homeserver, federating with nobody, with a closed and known set of user accounts that are, in practice, just me and people I trust implicitly. Second, every one of the machines in scope is already something I have unrestricted physical or root access to; RemoteOps isn’t granting a new privilege boundary crossing so much as offering a more convenient interface onto privileges I already unconditionally hold. Third, and perhaps most importantly, adding those extra guard rails properly — per-tool ACLs, request attribution, approval workflows — is meaningfully more engineering effort, and for a single-operator home network the actual residual risk is “I ask my own bot to do something silly to my own server”, which is a risk I already carry every time I open a root shell by hand. I’ve documented all of this explicitly, in the code’s own README and inline comments, specifically so that anyone (including future me) who considers exposing this bot beyond a trusted home environment has no excuse for missing quite how permissive it is.

Code breakdown: what it’s doing and how it works

The implementation deliberately avoids cleverness in favour of predictability, because a tool that can run sudo commands on my hypervisor is not the place to be experimenting with abstractions.

PointDescription
server.pyThe entry point. Defines a FastMCP application named RemoteOps and decorates nine plain Python functions as tools using @mcp.tool(). Started via mcp.run(transport="streamable-http", ...), so it behaves as a normal long-running web service rather than a spawned-per-request subprocess.
ssh_inventory.pyResponsible only for working out which hosts exist. It parses the mounted ~/.ssh/config file for Host stanzas, deliberately skipping wildcard patterns like Host * since those aren’t addressable targets in their own right. For each concrete alias, it shells out to ssh -G <alias> to ask OpenSSH itself for the fully resolved hostname, user, and port — rather than reimplementing OpenSSH’s (surprisingly intricate) config-merging rules in Python.
ssh_exec.pyThe workhorse. Runs commands via a real ssh subprocess, using -F <config> so connection behaviour always matches what I’d get typing ssh <alias> by hand. Privileged operations are wrapped as sudo -n bash -lc '<command>', so a whole pipeline runs elevated atomically, and the -n flag ensures a missing passwordless-sudo entry fails fast with a clear error rather than hanging on a password prompt that will never be answered. File writes pipe content over the SSH connection’s stdin into a remote tee command, sidestepping shell-quoting entirely.
safety.pyA small, self-contained module holding a handful of compiled regular expressions for genuinely catastrophic patterns, checked against the assembled command before it’s ever sent over the wire. Explicitly documented, in its own docstring, as a best-effort net rather than a security boundary.
audit.pyAn append-only JSON-lines logger, guarded by a lock for safe concurrent writes, recording tool name, target host, a truncated and sanitised detail payload, success/failure, exit code, and duration for every single tool invocation.
Tool functions in server.pyEach tool (remoteops_run_command, remoteops_read_file, remoteops_write_file, remoteops_edit_file, remoteops_list_directory, remoteops_service_status, remoteops_restart_service, remoteops_reboot_host, and remoteops_list_hosts) follows the same shape: validate the host is a known alias, run the safety check where relevant, delegate to ssh_exec.py, write an audit record, and return a small structured result.
remoteops_edit_file specificallyRather than attempting a remote sed-style regex substitution (an escaping nightmare), it reads the whole file back, performs an exact-substring Python str.replace, and writes the result back — failing loudly if the search text isn’t present, so the model can’t silently produce a no-op and report success.
remoteops_reboot_host specificallyRequires an explicit confirm=true argument in the tool call itself, then issues a detached, backgrounded reboot command over SSH so the connection dropping (because the host is rebooting) doesn’t hang the calling process.
Output truncationEvery tool caps its returned output at a configurable byte limit before handing it back to InfiniGPT, mirroring a similar safeguard InfiniGPT itself applies, to avoid a single verbose command blowing out the LLM’s context window or tripping provider-side request-size limits.

Deployment-wise, the whole thing is packaged as its own Docker image (docker/mcp-server/Dockerfile), built from python:3.12-slim with the OpenSSH client installed, and wired into the existing Compose stack as a new service alongside Synapse, InfiniGPT, and the rest. Enabling or disabling it is a single script invocation that edits InfiniGPT’s runtime configuration and recreates the relevant containers — consistent with how every other MCP integration in this stack is managed.

The outcomes

In practice, this changes InfiniGPT from a system I can ask “what’s wrong?” into one I can ask “what’s wrong, and please fix it” — with the caveat that I’m still the one reading its reasoning and its tool calls in the Matrix room before deciding whether I’m comfortable with what just happened. Concretely, the outcomes are as follows.

PointDescription
Genuine remediation capabilityInfiniGPT can now restart a hung service, correct a misconfigured file, or reboot an unresponsive host, rather than only reporting on those conditions.
Consistent operational surfaceEnabling, disabling, and validating RemoteOps looks identical to every other MCP integration already in the stack, so there’s no new mental model to hold.
A documented, honest risk postureRather than quietly shipping something permissive, the security trade-offs are written down in the code’s own README, in inline comments at the riskiest points (the reboot tool, the sudo wrapping), and now in this post.
A known, scoped set of follow-up workChief among the deferred items is threading Matrix sender identity through to the audit log, which would require patching InfiniGPT’s internal tool-dispatch call chain — a reasonable next step, deliberately not rushed into this iteration.
Verified, working integrationThe server was built, deployed, and smoke-tested against a real host in my SSH configuration, confirming that command execution, tool discovery, and the InfiniGPT-to-MCP-server wiring all function correctly end-to-end.

Conclusion

RemoteOps is a small piece of infrastructure with an outsized amount of thought behind the decisions I didn’t implement as much as the ones I did. Building an MCP server that can run arbitrary root-equivalent commands across a home network is, technically, not especially hard — SSH, sudo, and a handful of Python subprocess calls will get you there in an afternoon. The harder and more valuable work is being explicit about exactly what you’ve built, who can trigger it, and what the honest blast radius looks like, rather than assuming that “it works” is the same thing as “it’s fine”. For a single-operator home lab, where every target host is already something I hold unrestricted access to, and every user of the bot is someone I trust completely, I think this is a reasonable trade of convenience against risk. It is emphatically not a pattern I’d recommend transplanting into any environment with users you don’t fully trust, a threat model beyond “me, on my own network”, or any regulatory or compliance obligation whatsoever — and if I ever do extend this bot beyond my own four walls, per-tool access control and proper request attribution move from “nice to have” to “non-negotiable” before another line of this gets touched.

Leave a Reply

Your email address will not be published. Required fields are marked *