Your AI assistant can't see your server. You copy uptime, disk usage, and log lines into the chat by hand, or you wire a one-off script that only works inside a single app.
MCP (Model Context Protocol) is an open standard that fixes this. Build a tool once, and any MCP host can call it: Claude Desktop, Claude Code, Cursor, or a client you wrote yourself. This tutorial walks through building a small system-info server in Python, running it locally, testing it in the MCP Inspector, and connecting it to a host.
Prerequisites
- Linux or macOS (the example reads /proc, so Linux is the easiest target)
- Python 3.10 or newer
- uv installed (the official Python package manager; pip works too)
A note on the code: the tools read /proc, which is Linux-only. On macOS, replace those calls with ps or vm_stat. Windows is out of scope here.
Step 1: Set up the project
Create a project and install the MCP Python SDK (version 2.0 or later):
uv init system-info
cd system-info
uv add "mcp[cli]"
The [cli] extra adds the mcp command you will use during development. If you prefer pip: pip install "mcp[cli]".
Step 2: Write the server
Create server.py:
import logging
import os
import platform
from mcp.server import MCPServer
logger = logging.getLogger(__name__)
mcp = MCPServer("system-info")
@mcp.tool()
def read_uptime() -> str:
"""Read how long the host has been running."""
with open("/proc/uptime") as f:
seconds = float(f.readline().split()[0])
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
return f"{int(days)}d {int(hours)}h {int(minutes)}m"
@mcp.tool()
def disk_usage(path: str = "/") -> str:
"""Report disk usage for a given path."""
st = os.statvfs(path)
total = st.f_blocks * st.f_frsize
free = st.f_bavail * st.f_frsize
used = total - free
pct = (used / total) * 100 if total else 0
return f"{path}: {used / 2**30:.1f} GiB used of {total / 2**30:.1f} GiB ({pct:.0f}%)"
@mcp.tool()
def memory_info() -> str:
"""Report current memory usage from /proc/meminfo."""
with open("/proc/meminfo") as f:
data = dict(line.split(":", 1) for line in f)
total = int(data["MemTotal"].strip().split()[0])
available = int(data["MemAvailable"].strip().split()[0])
used = total - available
return f"{used / 2**20:.0f} MB used of {total / 2**20:.0f} MB total"
@mcp.resource("host://info")
def host_info() -> str:
"""Basic host identifier."""
return f"hostname={platform.node()} platform={platform.system()} {platform.release()}"
@mcp.prompt()
def summarize_system() -> str:
"""Summarize the state of this host."""
return "Summarize the current state of this host using the available tools."
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
mcp.run(transport="stdio")
The MCPServer class turns your type hints and docstrings into tool schemas automatically. You write plain functions with @mcp.tool() and the SDK generates the JSON Schema, handles the JSON-RPC messages, and manages the connection. No manual request parsing.
Three pieces are at play here:
@mcp.tool(): an action with side effects, like reading system state@mcp.resource(): read-only data, the equivalent of a GET endpoint@mcp.prompt(): a reusable prompt template
Step 3: Test it in the MCP Inspector
uv run mcp dev server.py
This boots the server and opens the MCP Inspector, an interactive UI (a Node.js app, so it needs npx on your PATH). Open the Tools tab, pick disk_usage, and call it. The form renders from your type hints. The subtle part: disk_usage(path="/") has a default value, so the SDK knows the argument is optional.
Step 4: Connect it to an MCP host
Wiring the server into a host is what makes it useful beyond a demo. Claude Desktop is the simplest example. Edit its config file:
~/.config/Claude/claude_desktop_config.json
and add:
{
"mcpServers": {
"system-info": {
"command": "uv",
"args": ["--directory", "/ABS/PATH/system-info", "run", "server.py"]
}
}
}
Use an absolute path, then fully quit Claude Desktop (Cmd+Q or quit from the tray, not just closing the window) and reopen it. A prompt like "what is the disk usage on /" will make Claude decide to call your tool.
Step 5: Log to stderr, never print
A stdio server communicates over stdout. Any stray print() corrupts the JSON-RPC stream and breaks the connection. Use the logging module instead, which writes to stderr:
logger.info("checking disk usage")
If your server ever needs to expose what it is doing while it runs, route it through a logger.
When to switch to HTTP transport
Stdio is the default and fits any local host. When other people or remote applications need to reach the server over a network, change the transport:
mcp.run(transport="http", port=8000)
Clients then connect to http://host:8000/mcp. The modelcontextprotocol.io docs recommend the Streamable HTTP transport for production deployments.
Troubleshooting
- Server does not show up in Claude Desktop: check the JSON syntax, confirm the path is absolute, and fully quit the app.
- Tool calls fail silently: look at Claude's logs at
~/.config/Claude/logs/mcp-server-*.logfor your server's stderr output. uv run server.pyprints nothing: that is expected, only the stdio protocol is running. Use the Inspector or a client to see tool output.
Next steps
- Run the server over HTTP and connect a remote client
- Add auth with OAuth 2.1 (the SDK ships an authorization guide)
- Mount the server inside an existing FastAPI or Starlette app
- Browse the official example servers for larger patterns
The loop you will repeat for any server you ship is the same: build, test in the Inspector, wire into a host. Start with the disk and memory tools here, then swap those bodies for whatever your own infrastructure actually exposes.