ยท11 min readยทAlgoMindset Team

Build an MCP Server in Python: A Complete Walkthrough

What you are building and why it is worth it

An MCP server exposes capabilities to any MCP-speaking client through one standard interface. Write it once and Claude Desktop, an IDE, and your own agent can all use it โ€” instead of writing the same integration three times.

That is the whole economic argument, and it is the answer to "why does this protocol exist" in an interview: it turns an Nร—M integration matrix into N+M. N clients, M servers, one contract between them.

A server offers three kinds of thing. Tools are functions the model can call. Resources are readable context addressed by URI. Prompts are reusable templates the server hands the client. Most servers are mostly tools, and this walkthrough concentrates there.

The smallest working server

FastMCP handles the protocol. You write decorated functions; it generates schemas from your type hints and docstrings and speaks MCP over stdio or HTTP.

Note what carries the weight here: the docstring is the tool description the model reads to decide whether to call this, and the type hints become the schema. They are interface, not documentation.

python
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("orders")

@mcp.tool()
def get_order(order_id: str) -> dict:
    """Fetch a single order by its reference.

    Use when you have an exact order reference. To find an order from a
    customer email, use search_orders instead.

    Args:
        order_id: Order reference, e.g. 'ORD-10432'.
    """
    row = db.orders.get(order_id)
    if row is None:
        return {"ok": False,
                "error": f"No order {order_id}.",
                "hint": "Check the reference, or use search_orders by email."}
    return {"ok": True, "order": shape(row)}

if __name__ == "__main__":
    mcp.run()

Transport: stdio or HTTP

stdio means the client launches your server as a subprocess and talks over standard input and output. It is the right default for anything local โ€” a developer tool, something reading local files โ€” because there is no network, no port, and no auth to get wrong.

HTTP is for a server that runs somewhere else and serves multiple clients. It brings authentication, transport security and multi-tenancy along with it, all of which are real work.

Start with stdio. Move to HTTP when you actually need a remote or shared server, and treat that as a distinct project rather than a flag change.

Return values are context, so shape them

Whatever you return is serialised into the model's context window. The two ways to get this wrong are returning a raw upstream response with forty fields the model does not need, and returning something unbounded that consumes the whole window in one call.

Return a small, shaped object. Cap list results and โ€” this is the field people omit โ€” say when you truncated, so the model knows there was more and can narrow rather than confidently reasoning over a partial set.

python
@mcp.tool()
def search_orders(email: str, limit: int = 10) -> dict:
    """Find orders belonging to a customer email address.

    Args:
        email: Full or partial address, e.g. 'ana@' or 'ana@acme.com'.
        limit: Max results. Default 10, capped at 50.
    """
    capped = min(limit, 50)
    rows = db.search(email, capped)
    total = db.count(email)
    return {
        "ok": True,
        "returned": len(rows),
        "total_matches": total,
        "truncated": total > len(rows),      # the model needs to know
        "orders": [{"id": r.id, "status": r.status, "total": r.total_cents}
                   for r in rows],           # three fields, not thirty
    }

Errors are messages, not exceptions

The contract from the earlier post in this series applies with full force across a protocol boundary: a raised exception becomes an opaque protocol error, and the model loses any chance of recovering.

Return structured failures with a hint that names the next action. Across MCP this matters more than in your own codebase, because the client is somebody else's agent and you have no ability to handle the failure for them.

python
@mcp.tool()
def run_report(sql: str) -> dict:
    """Run a read-only SELECT against the reporting warehouse."""
    if not sql.lstrip().upper().startswith("SELECT"):
        return {"ok": False,
                "error": "Only SELECT statements are permitted.",
                "hint": "Rewrite as a SELECT. Writes are not available here."}
    try:
        rows = warehouse.execute(sql, timeout=20).fetchall()
    except ColumnError as e:
        return {"ok": False,
                "error": str(e),
                "hint": f"Available columns: {', '.join(warehouse.columns())}"}
    except TimeoutError:
        return {"ok": False,
                "error": "Query exceeded 20s and was cancelled.",
                "hint": "Add a date filter or aggregate before returning rows."}
    return {"ok": True, "row_count": len(rows), "rows": rows[:100]}

Testing it without a model in the loop

Your tools are ordinary Python functions, so unit-test them ordinarily. The MCP-specific thing worth testing is the generated schema: that every tool has a description substantial enough to select on, that parameters are typed, and that nothing runtime-injected leaked into the public schema.

Then test with a real client before you publish. The MCP Inspector lets you list tools and call them by hand, which surfaces the mundane problems โ€” a description that reads fine to you and ambiguously to a model, a parameter whose format is unclear โ€” that no unit test catches.

bash
# interactive: list tools, call them, inspect responses
npx @modelcontextprotocol/inspector python server.py

# wire it into Claude Desktop's config to test in a real client
# claude_desktop_config.json
# {
#   "mcpServers": {
#     "orders": { "command": "python", "args": ["/abs/path/server.py"] }
#   }
# }

Before you hand it to anyone else

Two things separate a server people can use from one they abandon.

Scope. Every tool you expose is something a model somewhere will call. Read-only is a defensible default; anything destructive should be a separate, explicitly-enabled surface rather than sitting alongside the read tools by default.

Tool count. Selection accuracy falls as the surface grows, especially when tools overlap. Eight well-drawn capabilities beat thirty CRUD wrappers, and if you genuinely need a large surface, that is a signal to split into several focused servers.

The next post catalogues the seven mistakes that most often make an otherwise-working server unusable in practice.