1:1 mentoring with Big Tech AI engineers
Back to blog
By AgenticPrep Team10 min read

Build an MCP Server in Python — From Fifteen Lines to Deployable

A working server is about fifteen lines. The distance between that and one you would put in front of a customer is the rest of this post — and almost none of it is protocol.

mcppythontoolstutorial

A working server is about fifteen lines. The distance between that and one you would put in front of a customer is the rest of this post — and almost none of it is protocol.

Every team that wires a model into its own systems writes the same integration twice: once for the agent in the IDE, once for the one in the product, and again the next time either changes. MCP exists to make that a protocol problem instead of a copy-paste problem.

THE ONE-LINE VERSION

An MCP server is a small process that exposes your systems over a standard interface, so any MCP-speaking client can use them without knowing anything about you. Write the integration once; every agent you own gets it. See the MCP overview for how it fits the wider stack.

What a server can expose

Three primitives, and the distinction matters because clients treat them differently. Tools are model-invoked, resources are application-controlled, prompts are user-invoked. Most servers are all tools, and most servers that feel wrong are all tools when one of them should have been a resource.

One process, three primitives — and who decides to use each
WHAT AN MCP SERVER IS, STRUCTURALLY the client never learns anything about your internals AGENT HOST your app, an IDE, a desktop client stdio · HTTP JSON-RPC MCP SERVER TOOLS the model decides to call these RESOURCES the application decides to attach these PROMPTS the user picks these, by name your database · your APIs · your auth write the integration once here, and every agent you own inherits it

The smallest server that works

The Python SDK ships a decorator API that handles the protocol for you. This is a complete, runnable server.

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("orders")

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

    Use for order status, line items and shipping state.
    Do NOT use for refunds - call create_refund instead.
    """
    return db.fetch_order(order_id)

if __name__ == "__main__":
    mcp.run()   # stdio transport by default

That is the whole protocol surface. The type hints become the input schema, and — this is the part worth slowing down on — the docstring becomes the tool description the model reads when deciding whether to call it.

Point a client at it with a few lines of config:

{
  "mcpServers": {
    "orders": { "command": "python", "args": ["-m", "orders_server"] }
  }
}
WHERE THE TIME ACTUALLY GOES

Nobody gets stuck on the protocol. They get stuck on which operations deserve to be tools, what the descriptions say, and what happens when a call fails. That is the real work, and none of it is in the SDK docs.

The descriptions are the product

The model reads one tool description, out of context, with nothing else to go on, and decides. Treat it as a prompt rather than documentation — see tool surface design for the longer argument.

WeakStrongWhy it matters
“Gets order data.”“Look up one order by id. Use for status, items, shipping.”Says when, not just what
No negative case“Do NOT use for refunds — call create_refund.”The single highest-value line in most descriptions
data: dictorder_id: str, one purpose per parameterA loose dict makes the model invent keys
18 tools on one serverSplit by domain, or route before attachingSelection degrades past roughly fifteen

From works to deployable

1 · Return failures as data, not exceptions

A raised exception tells the model nothing it can act on. Return a result it can reason about — what failed, and whether retrying is sensible.

@mcp.tool()
def create_refund(order_id: str, amount_cents: int, idempotency_key: str) -> dict:
    """Issue a refund against an order. Requires an idempotency key."""
    if amount_cents > MAX_AUTO_REFUND:
        return {"ok": False, "reason": "over_limit",
                "detail": f"Refunds above {MAX_AUTO_REFUND} need human approval.",
                "retryable": False}
    return {"ok": True, "refund": payments.refund(order_id, amount_cents,
                                                  key=idempotency_key)}

The idempotency key is not decoration. A timeout does not tell you whether the write landed, so any write tool that can be retried needs one — see MCP in production.

2 · Put auth on the server, not in the prompt

The server is the security boundary, which is most of the reason to build one instead of calling the API directly. Propagate the user’s identity and check permissions there. A broad service account creates a confused deputy: someone who cannot read a table asks a question, the server can read it, and the answer contains it. MCP security covers the failure modes.

3 · Scope each server to a domain

One server per bounded area — orders, analytics, docs — rather than one company-wide server. Clients attach only what a task needs, which keeps tool counts in the range where selection still works, and it lets you give each server its own identity and rate limits.

4 · Version like the consumer is a model

Because it is. Adding a tool is safe. Changing a description is not, even though nothing about the schema moved — the model was choosing based on that text. Treat description changes as behavioural changes and re-run your evals.

5 · Remember descriptions are attacker-controlled if the server is

A third-party server controls text that lands directly in your model’s context. That is prompt injection with a supply chain attached. Pin what you install, review descriptions the way you review dependencies, and keep enforcement outside the model.

Testing it

Two layers, and most teams only build the first. Call the functions directly in unit tests — they are ordinary Python, that is the point of the decorator API. Then test the layer that actually breaks: whether a model, given these descriptions and a realistic request, picks the right tool.

# The test that catches what unit tests cannot: did it choose correctly?
CASES = [
    ("where is order A-91?",          "get_order"),
    ("refund the shipping on A-91",   "create_refund"),
    ("how many orders shipped today?", "run_report"),
]

def test_tool_selection(agent):
    for request, expected in CASES:
        assert agent.first_tool_call(request) == expected

Run it on every description change. It is the only thing that catches a reworded docstring quietly sending every refund request to get_order.

THE TAKEAWAY

The protocol is the easy part and will stay the easy part. What determines whether your server is any good is which operations you exposed, what the descriptions say, and whether a failed call leaves the agent with something it can act on. Get those right and switching model or client costs you nothing — which was the entire reason to build a server instead of a function.

From here: building MCP servers for the fuller walkthrough, transports for when stdio stops being enough, and skills vs subagents vs MCP if you are still deciding whether this capability belongs in a server at all.

Enjoyed this post? The full curriculum has 74+ sections, system design problems, and AI-reviewed practice runs.

See the full guide