Deep diveSelf-paced

Build your own MCP server

The live session shows you an agent *consuming* MCP tools. This deep dive is the other side: you build the server that exposes them. Once you have written one MCP server by hand, every "connect to MCP" instruction in a framework's docs stops being a black box.

The three primitives MCP servers expose

Tools

Model-callable

Named actions with a JSON Schema input. The model decides when to call these, analogous to function calling, but server-hosted and reusable across clients.

Resources

Context

Read-only data the host can attach to context (a file, a record, a query result) without the model having to call a tool first.

Prompts

Templates

Reusable prompt templates the host can surface to the user, parameterised, so the server can ship known-good phrasing.

Transport: pick one, know both exist

  • stdio - the server runs as a local subprocess of the host (Claude Code, Claude Desktop, your own script). Simplest to build and debug; this is what you will use today.
  • Streamable HTTP - the server runs remotely; clients connect over HTTP with an SSE stream for server-to-client messages. Use this once you need a shared server multiple people or services connect to.

Build one: a toy CRM tool server (Python, FastMCP)

The official Python SDK ships a high-level `FastMCP` class that turns a plain function into a tool via a decorator, handling JSON Schema generation and the protocol handshake for you.

requirements
pip install mcp
crm_server.py
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("toy-crm")

# In-memory "database" - swap for a real one later
TICKETS = {
    "T-101": {"customer": "Northwind Robotics", "status": "open", "owner": "aki"},
    "T-102": {"customer": "Harmony Apartments", "status": "closed", "owner": "manu"},
}

@mcp.tool()
def list_open_tickets() -> list[dict]:
    """Return every ticket whose status is 'open'. Use this before answering
    any question about current workload or backlog."""
    return [{"id": k, **v} for k, v in TICKETS.items() if v["status"] == "open"]

@mcp.tool()
def close_ticket(ticket_id: str, resolution: str) -> dict:
    """Mark a ticket closed with a one-line resolution note.
    Fails if the ticket_id does not exist - check with list_open_tickets first."""
    if ticket_id not in TICKETS:
        raise ValueError(f"No such ticket: {ticket_id}")
    TICKETS[ticket_id]["status"] = "closed"
    TICKETS[ticket_id]["resolution"] = resolution
    return TICKETS[ticket_id]

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

Why the docstrings are half the job

The model never reads your source code. It reads the tool name, the docstring, and the parameter schema. That is the entire interface it reasons over. "Return open tickets" is worse than "Return every ticket whose status is 'open'. Use this before answering any question about current workload." Specific docstrings are what turn a technically-correct tool into one the model actually picks at the right moment.

Wire it into a host

Point your MCP-capable client at the script. For Claude Code or Claude Desktop, add an entry to the client's MCP config pointing `command` at your Python interpreter and `args` at the script path. Restart the client, then ask it a question only `list_open_tickets` can answer, and watch the tool call happen in the logs.

example client config entry
{
  "mcpServers": {
    "toy-crm": {
      "command": "python",
      "args": ["/absolute/path/to/crm_server.py"]
    }
  }
}

Tip

Test the server without a host

The official `mcp` SDK ships an inspector (`npx @modelcontextprotocol/inspector python crm_server.py`) that lets you call tools directly and see raw JSON-RPC traffic - the fastest way to debug a schema mismatch before blaming the client.

Good server design, beyond the syntax

  • One server per capability domain (CRM, calendar, search) - not one mega-server with fifty unrelated tools.
  • Return structured errors the model can read and recover from, never let an exception crash the process silently.
  • Keep tools narrow and composable - `close_ticket` doing exactly one thing beats a `manage_ticket(action, ...)` grab-bag the model has to guess the arguments for.
  • Version your server. A tool signature change is a breaking change for every client already configured against it.

Watch out

Common mistakes

  • Shipping a tool with a one-word description and being surprised the model never calls it.
  • Letting a tool mutate state with no confirmation step and no audit log.
  • Building the remote HTTP transport before you have proven the tool logic works over stdio.
  • Treating the server as a black box you copy-pasted instead of code you can read end to end.