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.


Related posts