The goal
You keep hearing about MCP, and you want to know what it does and try it. In this post, you'll build a small server that lets an AI assistant check disk usage on your machine, and do nothing else.
What MCP is
The Model Context Protocol is an open standard that connects AI applications to tools and data. Before it, every assistant needed its own custom integration for every system. With MCP, you write one server, and any compatible client can use it.
| Part | Role | Example |
|---|---|---|
| Host or client | The AI application | A desktop assistant, IDE or agent |
| Server | Your program that offers capabilities | A disk check, a ticket lookup |
| Tool | An action that the model can ask to run | disk_usage |
| Resource | Data that the client can read | A log file or a runbook |
| Prompt | A reusable template | "Triage this alert" |
The model doesn't run anything itself. It asks for a tool call. The client (often after asking you) sends that call to the server, and your code runs it. Your server decides what's allowed.
Step 1: set up the project
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "mcp[cli]"
Step 2: write the server
Save this as ops_server.py:
import shutil
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("ops-checks")
ALLOWED_PATHS = {"/", "/var", "/home", "C:\\", "D:\\"}
@mcp.tool()
def disk_usage(path: str = "/") -> dict:
"""Return total, used and free disk space in GB for an allowed path.
Args:
path: A mount point. Must be one of the allowed paths.
"""
if path not in ALLOWED_PATHS:
return {"error": f"Path not allowed. Use one of: {sorted(ALLOWED_PATHS)}"}
usage = shutil.disk_usage(path)
gb = 1024 ** 3
return {
"path": path,
"total_gb": round(usage.total / gb, 1),
"used_gb": round(usage.used / gb, 1),
"free_gb": round(usage.free / gb, 1),
"free_pct": round(100 * usage.free / usage.total, 1),
}
if __name__ == "__main__":
mcp.run()
Why each part matters:
@mcp.tool()publishes the function as a tool.- The type hints become its input schema.
- The docstring is what the model reads to decide when and how to use the tool. Write it clearly, as if you were explaining it to a new teammate.
- The allowlist means that the model can't look at any path it likes.
Step 3: test it with the Inspector
Test without any AI involved:
mcp dev ops_server.py
Or run the Inspector directly, which needs Node.js:
npx @modelcontextprotocol/inspector python ops_server.py
It opens a page in your browser. Connect, choose Tools, pick disk_usage, enter / and run it. You should see the JSON result. Then try /etc, and check that you get the "not allowed" message.
Test this way first. It saves a lot of time, because any problem left after that lies with the client's setup.
Step 4: connect it to a client
The local transport is stdio: the client starts your server as a child process and talks to it through stdin and stdout. Many clients use a JSON config like this one. File locations and exact fields vary, so check your client's documentation.
{
"mcpServers": {
"ops-checks": {
"command": "/home/you/mcp-demo/.venv/bin/python",
"args": ["/home/you/mcp-demo/ops_server.py"]
}
}
}
On Windows, use the full path to .venv\Scripts\python.exe, with double backslashes in JSON.
Restart the client, and ask: "How much free space is on my root disk?" The client should show that it's using disk_usage.
Choose a transport
| Transport | How it works | Use when |
|---|---|---|
| stdio | Client starts a local process | Tools for one user, on their own machine |
| Streamable HTTP | The server runs as a web service | Shared tools for a team |
To run over HTTP:
mcp.run(transport="streamable-http")
Caution
Don't expose an HTTP MCP server without authentication. Anyone who can reach it can call its tools. Read MCP server security before you share one.
Fix common problems
| Problem | Likely cause | Fix |
|---|---|---|
| Server doesn't appear | Bad JSON, or the client wasn't restarted | Validate the JSON, and restart |
ModuleNotFoundError: mcp | The client uses a different Python | Use the full path to the venv's Python |
| Connection closes straight away | The script crashes at startup | Run it by hand to see the error |
| Tool works in Inspector, not in client | Different environment or working directory | Use full paths, and pass environment variables in the config |
| Protocol or JSON errors | Your code printed to stdout | Log to stderr instead |
| The model doesn't use the tool | Vague tool name or docstring | Describe when to use it |
The stdout one catches many people. On stdio, stdout carries the protocol, so a stray print() breaks it. Log like this instead:
import logging, sys
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
logging.info("server started")
More help is in MCP server not connecting.
Rules for safe tools
Start read-only, and be careful when adding anything else.
| Rule | Why |
|---|---|
| Keep each tool narrow | disk_usage is safer than run_shell |
| Validate every input | Tool arguments come from a model, and can be influenced by content it read |
| Use allowlists | Blocklists always miss something |
| Limit output size | Huge results waste tokens and leak data |
| Don't return secrets | Everything returned goes to the model |
| Log every call | You need a record of what ran |
Warning
Never add a general tool that runs any command or any SQL. Content that the model reads, such as a ticket or web page, can contain hidden instructions. That's called prompt injection, and a broad tool turns it into a real incident. Require human approval for anything that changes a system. See human-in-the-loop design.
What to try next
Add a second read-only tool:
@mcp.tool()
def service_status(name: str) -> dict:
"""Return whether an allowed service is active on this Linux host."""
import subprocess
allowed = {"nginx", "sshd", "docker"}
if name not in allowed:
return {"error": f"Service not allowed. Use one of: {sorted(allowed)}"}
result = subprocess.run(
["systemctl", "is-active", name],
capture_output=True, text=True, timeout=5,
)
return {"service": name, "status": result.stdout.strip()}
Notice that it passes a list, without shell=True, and only after checking the name. That stops input from being treated as a command.
Prevent it next time
- Test every server in the Inspector before connecting it to a client.
- Keep each server focused on a single job.
- Review tool descriptions as you would code.
- For a bigger example, read an MCP server for ops tooling.