Coding by Hand
Python home

Build an MCP Server

The face behind the pass-through window can write a haiku about your grandmother's cat. It cannot shuffle your poker deck. The chatbot page gave you a model that talks. The sdks page showed you the restaurant's wiring. This page hands the chef a speed dial. On one end is the chef — Gemini, Claude, any LLM. On the other end are your suppliers — the poker_score function you wrote on the project-poker page, the Deck.shuffle that lives in the same folder, a deal_hand that combines the two. Before today the chef could only describe a hand. Today the chef can ask for a real shuffle, get real cards, score a real hand, and tell you who wins. The speed dial is a protocol called MCP.

MCP stands for Model Context Protocol. Anthropic published the spec in November 2024 as an open standard — a JSON-RPC wire format for LLM clients to discover and call tools on a server. The lineage is short. OpenAI shipped plugins for ChatGPT in March 2023, and plugins were the first popular version of "an LLM calls your code." Function calling arrived in the GPT API in June 2023 as a cleaner replacement — the model emits a structured tool_call in its response and the client executes the function. Every vendor ended up shipping their own flavor, and a developer who wanted tools had to write one adapter per model. MCP was Anthropic's "let us agree on one wire format." Within 6 months, Claude Desktop, Cursor, Zed, and a dozen other clients could speak MCP, and any server you wrote once ran against all of them. By 2026 MCP is the default way an LLM touches the outside world.

The LLM client speaks MCP to a local Python server over stdio. Tools cross as JSON.
The LLM client speaks MCP to a local Python server over stdio. Tools cross as JSON.

Work inside your poker project from the project-poker lesson. Everything under src/poker/cards.py, score.py, player.py, game.py — is about to become a set of tools an LLM can call. Activate the venv and add the MCP package.

cd ~/learning-python/poker
source .venv/bin/activate
pip install mcp
cd $HOME\learning-python\poker
.venv\Scripts\Activate.ps1
pip install mcp

The mcp package holds two halves: the client the LLM runs on, and the server you are about to write. You need only the server half. Its FastMCP class takes regular Python functions, reads their type hints and docstrings, and publishes them as tools over a standard wire protocol called stdio — the server reads JSON lines from stdin and writes JSON lines to stdout. A client launches the server as a subprocess, pipes stdin and stdout, and speaks MCP across those pipes. No network, no TLS, no ports. The client owns the process.

Create a new file poker_mcp.py at the root of the poker project, next to main.py:

import json
import sys
from mcp.server.fastmcp import FastMCP
from poker.cards import Card, Deck, RANKS, SUITS
from poker.score import score_hand, best_of_seven
 
 
server = FastMCP("aarit-poker")
 
 
@server.tool()
def shuffle_deck(seed: int | None = None) -> list[str]:
    """Shuffle a fresh 52-card deck and return the cards in order.
 
    Each card is a 2-character string like 'As' (ace of spades) or 'Th' (ten of hearts).
    Suits: s, h, d, c. Ranks: 2-9, T, J, Q, K, A.
    """
    import random
    deck = Deck()
    if seed is not None:
        random.Random(seed).shuffle(deck.cards)
    else:
        deck.shuffle()
    return [f"{c.rank.replace('10', 'T')}{c.suit[0].lower()}" for c in deck.cards]
 
 
@server.tool()
def deal_hand(num_cards: int = 5, seed: int | None = None) -> list[str]:
    """Shuffle a fresh deck and deal the top N cards.
 
    Use this when an LLM needs a sample poker hand. Default is 5 cards for a full hand.
    """
    cards = shuffle_deck(seed=seed)
    return cards[:num_cards]
 
 
@server.tool()
def poker_score(cards: list[str]) -> dict[str, object]:
    """Score 5 or 7 poker cards and return the hand rank and tiebreakers.
 
    Accepts cards as 2-character strings like 'As', 'Kh', 'Qd', 'Jc', 'Ts'.
    Returns a dict with 'rank_name' (human label), 'rank' (1-10 integer), and
    'tiebreakers' (a tuple of ints comparing cards of the same rank).
    """
    parsed = [_parse_card(s) for s in cards]
    if len(parsed) == 5:
        tup = score_hand(parsed)
    elif len(parsed) == 7:
        tup = best_of_seven(parsed)
    else:
        raise ValueError(f"expected 5 or 7 cards, got {len(parsed)}")
    rank_names = {
        1: "high card", 2: "one pair", 3: "two pair", 4: "three of a kind",
        5: "straight", 6: "flush", 7: "full house", 8: "four of a kind",
        9: "straight flush", 10: "royal flush",
    }
    return {
        "rank_name": rank_names[tup[0]],
        "rank": tup[0],
        "tiebreakers": list(tup[1:]),
    }
 
 
SUIT_LOOKUP = {"s": "s", "h": "h", "d": "d", "c": "c"}
RANK_LOOKUP = {**{r: r for r in RANKS}, "T": "10"}
 
 
def _parse_card(s: str) -> Card:
    if len(s) != 2:
        raise ValueError(f"card must be 2 chars, got {s!r}")
    rank_char, suit_char = s[0].upper(), s[1].lower()
    if rank_char not in RANK_LOOKUP or suit_char not in SUIT_LOOKUP:
        raise ValueError(f"bad card {s!r}")
    return Card(rank=RANK_LOOKUP[rank_char], suit=SUIT_LOOKUP[suit_char])
 
 
if __name__ == "__main__":
    server.run()

The @server.tool() decorator is the whole API. FastMCP scans each decorated function's signature and docstring at import time. The type hint seed: int | None = None becomes a JSON Schema entry that says "optional integer." The docstring becomes the description the LLM reads when it decides whether to call the tool. The return type hint becomes the declared output shape. server.run() at the bottom is the event loop: read a JSON-RPC message from stdin, dispatch to the matching tool, write the result to stdout.

The card format shifts on the boundary. Inside the poker module a Card is Card(rank="A", suit="♠"). Over the wire you cannot count on a client's terminal rendering the Unicode suit, and the LLM is easier to prompt in pure ASCII. The tools translate: the string "As" becomes Card("A", "♠") on the way in, and "Th" becomes Card("10", "♥") with the T convention standard in poker writing. The _parse_card helper handles validation — bad input raises ValueError and MCP routes the message back to the client as a tool error.

Before handing the server to a real LLM, prove it works with the MCP client bundled in the mcp package. Save this second file as probe.py in the same folder:

import asyncio
import json
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
 
 
async def main() -> None:
    params = StdioServerParameters(
        command="python",
        args=["poker_mcp.py"],
    )
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
 
            tools = await session.list_tools()
            print("--- tools advertised by server ---")
            for tool in tools.tools:
                print(f"  {tool.name}: {tool.description.splitlines()[0]}")
 
            print("\n--- calling deal_hand(5, seed=42) ---")
            deal_req = {"num_cards": 5, "seed": 42}
            print("client sends:", json.dumps({"tool": "deal_hand", "args": deal_req}))
            hand = await session.call_tool("deal_hand", deal_req)
            cards = json.loads(hand.content[0].text)
            print("server returns:", cards)
 
            print("\n--- calling poker_score(cards) ---")
            score_req = {"cards": cards}
            print("client sends:", json.dumps({"tool": "poker_score", "args": score_req}))
            score = await session.call_tool("poker_score", score_req)
            print("server returns:", score.content[0].text)
 
 
if __name__ == "__main__":
    asyncio.run(main())

StdioServerParameters is the launch config: python poker_mcp.py with the current venv's Python. stdio_client spawns the subprocess and hands you the two ends of its stdio pipes. ClientSession wraps them in the JSON-RPC framing. session.initialize() does the MCP handshake — client and server exchange protocol versions, then the client asks "what tools do you offer?" and caches the list. call_tool ships a single tools/call request over the pipe and awaits the response.

Run it with python probe.py. A sample run:

--- tools advertised by server ---
  shuffle_deck: Shuffle a fresh 52-card deck and return the cards in order.
  deal_hand: Shuffle a fresh deck and deal the top N cards.
  poker_score: Score 5 or 7 poker cards and return the hand rank and tiebreakers.
 
--- calling deal_hand(5, seed=42) ---
client sends: {"tool": "deal_hand", "args": {"num_cards": 5, "seed": 42}}
server returns: ['Kh', '5s', 'Qc', '9h', '8d']
 
--- calling poker_score(cards) ---
client sends: {"tool": "poker_score", "args": {"cards": ["Kh", "5s", "Qc", "9h", "8d"]}}
server returns: {"rank_name": "high card", "rank": 1, "tiebreakers": [13, 12, 9, 8, 5]}

The probe printed both sides of the wire: what the client sent and what the server returned. The actual JSON-RPC envelope around each of those has extra framing — a jsonrpc: "2.0" version, an id for request/response matching, and a method: "tools/call" field — but the payload is what you see. The seed makes the shuffle reproducible, which is the reason it is a parameter. An LLM calling this tool twice with the same seed will get the same deal every time.

Claude Desktop launches your server at startup and shows the tool-call cards inline in chat.
Claude Desktop launches your server at startup and shows the tool-call cards inline in chat.

Point a real LLM client at the server. Claude Desktop reads a config file and launches every server listed in it at startup. Open the config in your editor.

mkdir -p ~/Library/Application\ Support/Claude
code ~/Library/Application\ Support/Claude/claude_desktop_config.json
New-Item -Force -ItemType Directory "$env:APPDATA\Claude" | Out-Null
code "$env:APPDATA\Claude\claude_desktop_config.json"

Add an entry under mcpServers pointing at your poker server. The command is the absolute path to the venv's Python, and the args is the absolute path to poker_mcp.py. Claude Desktop launches the process, pipes stdio, and speaks MCP across the pipes — same as probe.py did.

{
  "mcpServers": {
    "aarit-poker": {
      "command": "/Users/you/learning-python/poker/.venv/bin/python",
      "args": ["/Users/you/learning-python/poker/poker_mcp.py"]
    }
  }
}
{
  "mcpServers": {
    "aarit-poker": {
      "command": "C:\\Users\\you\\learning-python\\poker\\.venv\\Scripts\\python.exe",
      "args": ["C:\\Users\\you\\learning-python\\poker\\poker_mcp.py"]
    }
  }
}

Quit Claude Desktop, launch it again, open a new chat, and ask: "Deal me a 5-card hand and tell me what I have." Claude reads its tool list, sees deal_hand and poker_score, and in its response a small "used tool" card appears next to each call. Expand it and the exact JSON payload from the tool call appears, the same shape probe.py printed. Ask Claude "give me a royal flush," and it cannot — the deck is real and the shuffle is random — so it calls deal_hand until it either gets one or gives up and admits the odds. That refusal is the point. The LLM is no longer guessing card values. It is calling your scorer.

A question to answer from the probe run: the tool list printed 3 tools — shuffle_deck, deal_hand, poker_score — but deal_hand internally calls shuffle_deck. Why expose both as tools instead of hiding shuffle_deck as a helper?

Because the LLM decides which tool fits the question. If the user asks "shuffle a deck for me and read me the cards," deal_hand(num_cards=52) works but shuffle_deck() is the direct match and the LLM picks it. If the user asks "deal me 3 sample cards from the top," deal_hand(num_cards=3) is the direct match. Exposing both lets the model pick the cheapest, clearest call. The tool surface is a menu — name each tool after one thing, describe it in one line, and the LLM reads the menu the same way a customer reads a restaurant menu. A tool named "do_poker_stuff" forces the model to guess. A tool named "shuffle_deck" does not.

You have a Python program whose inside is a set of real functions with real types, and whose outside is a protocol any LLM can call. The chef can dial your suppliers. The contract from the philosophy page held — every line of poker_mcp.py was written by you, not by a model. The model gets to call it. That is the trade you wanted all along: your code, sharper tools. The next bottleneck is the one the deep learning section opens up — how a neural network actually learns to decide which tool to pick in the first place.