I got tired of copy-pasting df -h output into Claude Code every time I asked it why a box was full. So I wrote a tiny MCP server that lets Claude run a fixed set of read-only sysadmin commands itself. Now it just checks.
TL;DR:
// server.mjs
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
const server = new McpServer({ name: "sysadmin-tools", version: "1.0.0" });
server.tool("disk_usage", { path: z.string().default("/") }, async ({ path }) => {
const { stdout } = await run("df", ["-h", path]);
return { content: [{ type: "text", text: stdout }] };
});
const transport = new StdioServerTransport();
await server.connect(transport);
Details below.
What MCP actually is
MCP (Model Context Protocol) is an open protocol for handing tools and resources to an LLM client — Claude Code, Claude Desktop, whatever speaks it. You write a server that exposes tools (callable functions with JSON schemas), the client discovers them, and the model can call them mid-conversation. The client runs the model; your server runs the work.
For a local server you talk over stdio: the client spawns your process and exchanges JSON-RPC messages on stdin/stdout. There's also an HTTP transport for remote servers, but for "run a command on the host Claude is already on", stdio is the simplest thing that works and the one I use.
I'm using the official TypeScript SDK (@modelcontextprotocol/sdk). There's a first-party Python SDK too if you'd rather; the shapes are the same. Node is what I reach for because most of my host tooling is already there.
Build the server
Project setup is boring:
mkdir sysadmin-mcp && cd sysadmin-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/sdk zod
A tool is three things: a name, a Zod schema for its inputs, and an async handler that returns content. Here's the disk tool plus a service_status wrapper around systemctl:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
const server = new McpServer({ name: "sysadmin-tools", version: "1.0.0" });
server.tool(
"disk_usage",
{ path: z.string().default("/").describe("Mount point or path to check") },
async ({ path }) => {
const { stdout } = await run("df", ["-h", path]);
return { content: [{ type: "text", text: stdout }] };
}
);
// Allowlist — the model picks a name, never a raw argument string
const ALLOWED = new Set(["nginx", "ssh", "docker", "fail2ban"]);
server.tool(
"service_status",
{ service: z.string().describe("Service name, e.g. nginx") },
async ({ service }) => {
if (!ALLOWED.has(service)) {
return {
content: [{ type: "text", text: `Service '${service}' not allowed.` }],
isError: true,
};
}
try {
const { stdout } = await run("systemctl", [
"status", service, "--no-pager", "--lines=10",
]);
return { content: [{ type: "text", text: stdout }] };
} catch (err) {
// systemctl exits non-zero for a dead unit — that's data, not a crash
return { content: [{ type: "text", text: err.stdout || String(err) }] };
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
Two things I want to flag right now because they bit me.
execFile, not exec. execFile("df", ["-h", path]) passes arguments as an array — no shell, no word-splitting, no ; rm -rf / smuggled through the path field. The moment you reach for exec("df -h " + path) you've built a remote shell for whatever the model hallucinates. Don't.
systemctl status returns a non-zero exit code for stopped or failed units, which makes execFile throw even though the output is exactly what you wanted. I catch it and return err.stdout instead of letting it blow up.
Register it with Claude Code
Two ways. The quick one:
claude mcp add sysadmin-tools -- node /opt/sysadmin-mcp/server.mjs
Or commit it to the project so it travels with the repo — drop a .mcp.json at the project root:
{
"mcpServers": {
"sysadmin-tools": {
"command": "node",
"args": ["/opt/sysadmin-mcp/server.mjs"]
}
}
}
command + args is just how the client spawns your stdio process — same as you'd type it in a shell. Restart Claude Code, and /mcp should list sysadmin-tools with its two tools. Ask it "is nginx up on this box?" and it'll call service_status instead of asking you to paste output.
This pairs nicely with the rest of my Claude setup — see my Claude Code hooks for automating workflow and how I drive PowerShell from Claude.
Debugging when it doesn't show up
Nine times out of ten the server crashed on startup and the client silently dropped it. Run it by hand:
node /opt/sysadmin-mcp/server.mjs
It'll sit there waiting for JSON-RPC on stdin — that's correct, it means it booted. Ctrl-C out. If it threw a stack trace instead, fix that first. There's also npx @modelcontextprotocol/inspector node server.mjs, a little web UI that lists your tools and lets you invoke them by hand without involving the model at all. I use it to confirm a tool works before I ever wire it to Claude.
Gotchas
Never write to stdout. This is the one that wastes an afternoon. On a stdio server, stdout is the protocol channel — every byte is framed JSON-RPC. A stray console.log("starting up") injects garbage into the stream and the client drops the connection with no useful error. All your logging goes to stderr: console.error(...). Same rule in Python — print to stderr, never stdout.
A shell-running tool is a foot-gun. Scope it hard. This server can read disk usage and service state, full stop. It cannot take a service name that isn't on the allowlist, cannot take an arbitrary command, cannot write anything. That's deliberate. The model is good and getting better, but it is still a thing that occasionally invents plausible nonsense, and prompt injection through a fetched web page or a log line it just read is real. Treat every tool input as hostile: array args, allowlists, no string interpolation into commands. If you want it to do something destructive — restart a service, prune images — gate that behind a separate tool that you've thought about much harder than this one, and ideally one that needs a human to confirm.
The exception: on a throwaway lab VM I sometimes do expose a looser run_command tool, because the blast radius is a snapshot rollback. On anything I'd miss, the allowlist stays.
Errors are content, not exceptions. Return { content: [...], isError: true } so the model sees what went wrong and can adjust. If you let the handler throw, the model gets a generic failure and tends to just retry the same broken call.
The whole thing is maybe 50 lines and it's changed how I work — Claude checks the box itself before it theorizes about the box. Next I want a journalctl tail tool with a hard line cap. For the prompts I run against all this, see my sysadmin AI prompt cheatsheet.