Home / AI & GenAI

AI & GenAIHow-toBeginner

MCP basics: what the Model Context Protocol does, and your first read-only server in Python

Understand MCP clients, servers, tools and transports, then build a small read-only Python server for disk checks, test it with MCP Inspector, connect it to a client and fix the usual startup problems.

Deepak Bhardwaj6 min read

Applies toModel Context Protocol, MCP Python SDK, MCP Inspector, Python 3.10+

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.