Every AI app ends up writing the same glue code. A model wants to call a function, so you hand-build a function-calling schema, write a handler per vendor, and the moment you swap models the whole thing breaks. The Model Context Protocol (MCP) is Anthropic's open standard that turns that pile of bespoke wiring into one socket any model and any client can reuse.
This tutorial builds a working MCP server in a few minutes and connects it to a real host. You end with something you can actually use, not a toy.
Prerequisites
- Python 3.10 or newer
uvinstalled (the fast Python package manager,curl -LsSf https://astral.sh/uv/install.sh | sh)- An MCP host client: Claude Desktop or Claude Code
- A GitHub account (the API we call is public, reads need no token)
What MCP actually gives you
MCP defines three building blocks, and thinking of them as HTTP verbs keeps it simple:
- Tools behave like POST endpoints. The model calls them to execute code or produce a side effect. This is the core you will use most.
- Resources behave like GET endpoints. They load data into the model's context without the model choosing to call anything.
- Prompts are reusable templates that tell the model how to respond to a task.
A server exposes some mix of these. A host (Claude, an app you build) connects to that server over stdio or HTTP and lets the model use whatever is exposed.
Step 1: set up the project
mkdir mcp-demo && cd mcp-demo
uv init --app
uv add "mcp[cli]" httpx
mcp[cli] pulls in the official Python SDK plus the mcp command-line tool for running and testing your server. You need MCP SDK 2.0.0 or newer; uv add grabs the latest.
Step 2: write your first server
Create server.py. This one exposes a tool that lists a GitHub user's public repositories:
# server.py
from mcp.server.fastmcp import FastMCP
import httpx
mcp = FastMCP("GitHub Helper")
@mcp.tool()
async def get_user_repos(username: str) -> str:
"""List public repos for a GitHub user with star counts."""
url = f"https://api.github.com/users/{username}/repos"
async with httpx.AsyncClient() as client:
resp = await client.get(url, headers={"Accept": "application/vnd.github+json"})
resp.raise_for_status()
repos = resp.json()
if not repos:
return "No public repos found."
lines = [
f"{r['name']} ({r['stargazers_count']} stars) - {r['description'] or 'no description'}"
for r in repos
]
return "
".join(lines)
if __name__ == "__main__":
mcp.run()
The docstring and the username: str type hint are not decoration. The SDK turns them into the JSON schema the model uses, so the model knows what arguments to pass. Describe tools in the docstring the way you would explain them to a colleague, because that text is what the model reads when deciding whether to call the tool.
Step 3: test it locally
mcp run server.py
That starts the server over stdio and prints the tools it registered. To call a tool without a host, use the dev client:
mcp dev server.py
This opens an interactive REPL where you can call get_user_repos directly and watch the tool output before any model is involved. Testing the tool in isolation now saves you from debugging a model plus a tool at the same time later.
Step 4: connect it to a host
Claude Desktop reads a config file at ~/Library/Application Support/Claude/claude_desktop_config.json. Point it at your server:
{
"mcpServers": {
"github-helper": {
"command": "uv",
"args": ["run", "--directory", "/absolute/path/to/mcp-demo", "server.py"]
}
}
}
Restart Claude Desktop. The model should now be able to call get_user_repos whenever a question involves GitHub.
Claude Code (the CLI) uses its own command instead of a config edit:
claude mcp add github-helper -- uv run --directory /absolute/path/to/mcp-demo server.py
Verify it with claude mcp list. Ask Claude something like "list the top repos for localhost94" and watch it reach for the tool.
Step 5: add a resource and a prompt
Tools alone get you far, but the other two blocks are cheap and change how useful the server feels. Add a resource that injects configuration, and a prompt that structures a common task:
@mcp.resource("config://app")
def get_config() -> str:
return """app_name: GitHub Helper MCP
version: 0.1.0
default_owner: localhost94
"""
@mcp.prompt()
def repo_roundup(username: str) -> str:
return f"Give a short profile of GitHub user {username}, then list their three most-starred repos."
Now a host can load config://app into context without being told, and the prompt gives the model a template for a recurring request. One server, three ways to hand the model what it needs.
MCP vs writing your own tool-calling loop
If your app talks to exactly one model and the tools are two internal functions, a plain function-calling loop is fewer moving parts. Skip the protocol until one of these is true:
- More than one client or model must reuse the same tools. MCP makes the tool boundary a contract instead of copy-pasted code.
- You want resources and prompts, not just calls. Plain loops only give you the call.
- You plan to ship a tool other people can wire into their own agents. MCP is the format Claude Desktop, Claude Code, and most agent frameworks already speak.
Reach for MCP when the socket matters, not when a single direct call would do.
Where to go next
- Call GitHub or other APIs that rate-limit. Add caching inside the tool so repeated calls stay cheap.
- Secure the server. For HTTP transport, add a token check before
mcp.run()and consider streaming mode for long jobs. - Serve it over HTTP (streamable transport) instead of stdio so a web app, not just a desktop client, can connect.
- Read the typescript-sdk if your stack is Node instead of Python.
The pattern to remember is small: schema comes from your type hints and docstrings, the tool is plain async Python, and the host figures out how to call it. That is the whole trick.