> ## Content Index
> Fetch the complete content index at: https://blog.sudowl.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Building an MCP Server to Give Claude My Sysadmin Tools
- URL: https://blog.sudowl.com/build-mcp-server-sysadmin-tools/
- Published: 2026-05-23T10:00:00.000Z
- Updated: 2026-10-11T08:47:37.000Z
- Description: How I built a small MCP server in Node so Claude Code can run disk_usage and service_status on my hosts. Code, registration, and the security gotchas.
- Author: Lukas
- Tags: AI

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:**

```js
// 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:

```bash
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`:

```js
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:

```bash
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:

```json
{
  "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](https://blog.sudowl.com/claude-code-hooks-automate-workflow/) and [how I drive PowerShell from Claude](https://blog.sudowl.com/how-i-use-claude-powershell-scripts/).

## 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:

```bash
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](https://blog.sudowl.com/sysadmin-ai-prompt-cheatsheet/).

---

### Related posts

- [Claude Code Hooks — Automating the Boring Parts of My Workflow](https://blog.diengdoh.com/claude-code-hooks-automate-workflow/?ref=blog.sudowl.com)
- [Claude Code Superpowers: How I Brainstorm Features Before Writing a Single Line](https://blog.diengdoh.com/claude-code-superpowers-brainstorming/?ref=blog.sudowl.com)
- [How I Use Claude to Write PowerShell Scripts in 30 Seconds](https://blog.diengdoh.com/how-i-use-claude-powershell-scripts/?ref=blog.sudowl.com)