The Model Context Protocol went from 100,000 SDK downloads per month to 97 million in eighteen months. Every major AI coding tool - Claude Code, Cursor, Windsurf, VS Code Copilot, and JetBrains AI - now speaks MCP natively. Anthropic donated the spec to the Linux Foundation in December 2025, confirming that MCP is infrastructure, not a product.
But nearly all the tutorials that exist are either "here is a list of servers to install" or a dry reading of the official spec. Very few walk you through actually building one from scratch - deciding which tools to expose, writing the handlers, testing with a live client, and shipping something that works reliably inside Claude.
This guide does exactly that. By the end, you will have built a real Python MCP server with tools, resources, and prompts, tested it with the MCP Inspector, and connected it to Claude Desktop or Claude Code. The final server calls the GitHub API - a practical, reusable starting point for any project that needs to surface external data inside an AI assistant.
What you need: Python 3.10+, uv (recommended) or pip, and either Claude Desktop or Claude Code installed.
What Is an MCP Server, Really?
MCP (Model Context Protocol) is a JSON-RPC protocol that lets an AI client - Claude, Cursor, or any other compliant tool - call external services through a standardized interface. The client does not know or care whether your server is written in Python, TypeScript, or Go. It sends a request, your server handles it, and the response flows back.
An MCP server exposes three primitives:
Tools are functions the AI can invoke. When you ask Claude "how many open issues does this repo have?", it calls a tool, your server makes an API request, and returns the answer. Tools are the most commonly used primitive.
Resources are data the AI can read but not directly invoke - think of them as files or endpoints the AI can fetch. A resource might expose the contents of a config file, a database record, or a URL.
Prompts are reusable templates. You define a prompt once in the server, and any client can reference it by name. This is useful for standardizing common instructions across a team.
The two transport modes you will encounter:
| Transport | When to use | How it works |
|---|---|---|
| stdio | Local development, Claude Desktop, Claude Code | Client spawns your script as a subprocess; communication over stdin/stdout |
| Streamable HTTP | Remote or production servers | Your server listens on a port; client connects over HTTP |
For this guide, we use stdio - it is simpler to set up and works immediately with Claude Desktop and Claude Code.
Project Setup
Create a new directory and install the official MCP SDK. The [cli] extra installs the mcp dev development server and inspector launcher:
Bashmkdir github-mcp-server cd github-mcp-server uv init . uv add "mcp[cli]" httpx
If you prefer pip:
Bashmkdir github-mcp-server && cd github-mcp-server pip install "mcp[cli]" httpx

Your project structure will look like this:
Bashgithub-mcp-server/ ├── server.py ├── pyproject.toml (uv only) └── .env (optional, for GITHUB_TOKEN)

Your First Tool in 10 Lines
Before the real-world server, here is the minimal working example. Create server.py:
Pythonfrom mcp.server.fastmcp import FastMCP mcp = FastMCP("hello-mcp") @mcp.tool() def greet(name: str) -> str: """Return a personalized greeting. Use this when asked to greet someone.""" return f"Hello, {name}! Your MCP server is working." if __name__ == "__main__": mcp.run(transport="stdio")
Test it immediately with the built-in MCP Inspector - no Claude required:
uv run mcp dev server.py

This opens an interactive web UI at http://localhost:5173. Navigate to Tools, find greet, enter a name, and click Run Tool. You should see your greeting returned.

Three things to notice in the code above:
FastMCPis the high-level API inside the officialmcppackage. It handles all the JSON-RPC plumbing.- The
@mcp.tool()decorator registers a Python function as a callable MCP tool. The function's type hints become the tool's input schema automatically. - The docstring is critical - it is what the AI reads to decide whether to call this tool. Write it like you are explaining the tool to a developer who has never seen your code.
Building a Real Server: GitHub Tools
Now build something useful. This server exposes two GitHub tools: one to fetch repository metadata and one to list open issues. Both make real HTTP requests using httpx.
Create or replace server.py with the following:
Pythonimport os import logging import sys import httpx from pydantic import BaseModel from mcp.server.fastmcp import FastMCP # Log to stderr - never stdout (stdout is the stdio transport channel) logging.basicConfig(stream=sys.stderr, level=logging.INFO) logger = logging.getLogger(__name__) mcp = FastMCP( "github-tools", instructions=( "This server provides tools to interact with the GitHub API. " "Use get_repo_info to fetch repository metadata. " "Use list_open_issues to retrieve open issues for a repository." ), ) GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") def _github_headers() -> dict[str, str]: headers = { "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", } if GITHUB_TOKEN: headers["Authorization"] = f"Bearer {GITHUB_TOKEN}" return headers class RepoInfo(BaseModel): full_name: str description: str | None stars: int forks: int open_issues: int language: str | None url: str @mcp.tool() async def get_repo_info(owner: str, repo: str) -> RepoInfo: """ Fetch metadata for a GitHub repository including stars, forks, and open issue count. Args: owner: GitHub username or organization name (e.g. 'anthropics') repo: Repository name (e.g. 'claude-code') """ async with httpx.AsyncClient() as client: try: response = await client.get( f"https://api.github.com/repos/{owner}/{repo}", headers=_github_headers(), timeout=10.0, ) response.raise_for_status() except httpx.TimeoutException: raise ValueError(f"Request timed out fetching {owner}/{repo}") except httpx.HTTPStatusError as e: raise ValueError( f"GitHub API error {e.response.status_code} for {owner}/{repo}" ) data = response.json() return RepoInfo( full_name=data["full_name"], description=data.get("description"), stars=data["stargazers_count"], forks=data["forks_count"], open_issues=data["open_issues_count"], language=data.get("language"), url=data["html_url"], ) @mcp.tool() async def list_open_issues(owner: str, repo: str, limit: int = 10) -> str: """ List open issues for a GitHub repository, ordered by most recently updated. Args: owner: GitHub username or organization name repo: Repository name limit: Maximum number of issues to return (1–30, default 10) """ limit = max(1, min(limit, 30)) # clamp to safe range async with httpx.AsyncClient() as client: try: response = await client.get( f"https://api.github.com/repos/{owner}/{repo}/issues", headers=_github_headers(), params={"state": "open", "per_page": limit, "sort": "updated"}, timeout=10.0, ) response.raise_for_status() except httpx.TimeoutException: return "Error: Request timed out." except httpx.HTTPStatusError as e: return f"Error: GitHub API returned HTTP {e.response.status_code}." issues = response.json() if not issues: return f"No open issues found for {owner}/{repo}." lines = [f"Open issues in **{owner}/{repo}** (showing {len(issues)}):\n"] for issue in issues: lines.append(f"- #{issue['number']}: {issue['title']}") return "\n".join(lines) if __name__ == "__main__": mcp.run(transport="stdio")

Several deliberate choices in this code are worth explaining:
Pydantic return types on tools. When a tool returns a Pydantic model, MCP automatically serializes it and the AI receives a structured, typed response. This is far more reliable than returning a formatted string - the AI can reference individual fields (repo.stars, repo.language) rather than parsing text.
Error handling that returns, not raises. For tools that return strings, catching exceptions and returning an error message is preferable to letting exceptions propagate. An unhandled exception in a stdio server can terminate the connection. Tools that return Pydantic models can raise ValueError - the MCP server catches these and returns a structured error response to the client.
Clamping inputs. The limit parameter is clamped to a safe range (1–30) before use. Never trust that the AI will pass a valid value - it will occasionally pass 0, 100, or a string.
PYTHONUNBUFFERED and stderr logging. Python buffers stdout by default. Under stdio transport, this means MCP responses sit in the buffer and the client hangs. Always log to sys.stderr, never sys.stdout, and set PYTHONUNBUFFERED=1 in your environment or client config.
Adding Resources and Prompts
Resources
Resources expose data the AI can read without invoking a tool. Add a resource that returns the server's configuration as a string:
Python@mcp.resource("config://github-tools/status") def server_status() -> str: """Report whether the server has a GitHub token configured.""" auth_status = "authenticated" if GITHUB_TOKEN else "unauthenticated (rate-limited to 60 req/hr)" return f"GitHub Tools MCP Server\nStatus: {auth_status}"
Resources use URI-style identifiers (scheme://path). You can also define dynamic resources with URI template parameters:
Python@mcp.resource("github://repos/{owner}/{repo}/readme") async def get_readme(owner: str, repo: str) -> str: """Fetch the raw README content for a repository.""" async with httpx.AsyncClient() as client: response = await client.get( f"https://api.github.com/repos/{owner}/{repo}/readme", headers={**_github_headers(), "Accept": "application/vnd.github.raw+json"}, timeout=10.0, ) if response.status_code == 404: return "No README found for this repository." response.raise_for_status() return response.text
Prompts
Prompts are reusable templates that any MCP client can reference by name. Add a prompt that helps structure code review requests:
Python@mcp.prompt() def review_pull_request(owner: str, repo: str, pr_number: int) -> str: """Prompt template for reviewing a GitHub pull request.""" return ( f"Please review pull request #{pr_number} in {owner}/{repo}. " f"Start by fetching the repository info with get_repo_info, " f"then list the open issues to understand the project context. " f"Focus your review on correctness, performance, and adherence to the project's patterns." )
When a user invokes this prompt in Claude, Claude receives the formatted instruction along with access to the tools it references. Prompts are particularly useful for standardizing common workflows across a team.
Testing with the MCP Inspector
The MCP Inspector is the fastest way to validate your server without needing a live Claude instance:
uv run mcp dev server.py
This launches the inspector UI at http://localhost:5173.

Set your GITHUB_TOKEN in the Environment Variables section before connecting, then test each tool in the Tools tab, verify resources in the Resources tab, and confirm your prompts appear in the Prompts tab.
If a tool fails, check the Logs panel - it shows the raw JSON-RPC messages exchanged between the inspector and your server, which is invaluable for debugging.

Connecting to Claude Desktop
Edit the Claude Desktop configuration file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Add your server under mcpServers. Use an absolute path to server.py:
JSON{ "mcpServers": { "github-tools": { "command": "uv", "args": [ "run", "--with", "mcp[cli]", "--with", "httpx", "python", "/absolute/path/to/github-mcp-server/server.py" ], "env": { "PYTHONUNBUFFERED": "1", "GITHUB_TOKEN": "your_github_token_here" } } } }
Using uv run here is deliberate: it ensures the correct virtual environment and dependencies are used regardless of what Python is active in your shell. Using a bare python command often fails because Claude Desktop spawns its own shell environment without your shell's PATH.
Fully quit and restart Claude Desktop after saving the config. When the server connects, a small plug icon appears in the bottom-right corner of the input box. Click it to confirm your tools are listed.
To debug connection issues, check the Claude Desktop logs:
# macOS
tail -f ~/Library/Logs/Claude/mcp*.log
Connecting to Claude Code
If you use Claude Code instead of Claude Desktop, use the claude mcp add command. The -- separator is required - it separates the server name from the command that launches it:
Bashclaude mcp add github-tools \ -e GITHUB_TOKEN=your_token \ -e PYTHONUNBUFFERED=1 \ -- uv run --with "mcp[cli]" --with httpx python /absolute/path/to/server.py
To make the server available across all your projects:
Bashclaude mcp add --scope user github-tools \ -e GITHUB_TOKEN=your_token \ -e PYTHONUNBUFFERED=1 \ -- uv run --with "mcp[cli]" --with httpx python /absolute/path/to/server.py
To share the server configuration with your team by committing it to the repository:
Bashclaude mcp add --scope project github-tools \ -e PYTHONUNBUFFERED=1 \ -- uv run --with "mcp[cli]" --with httpx python /absolute/path/to/server.py
The project scope writes an .mcp.json file to your repository root. Team members who open the project in Claude Code will be prompted to trust and activate the server.
Verify the server is registered:
claude mcp list
Conclusion
MCP gives AI models a structured, auditable surface to act on your systems - not just answer questions about them. FastMCP makes the entry cost low: a working server is an afternoon's work. The real leverage is what you expose. Typed tools backed by your internal knowledge turn an assistant into something closer to a peer. Build it, test it with Claude Desktop, and publish it - well-scoped servers for specific domains are exactly what the registry needs more of.
Related Tools on DevToolLab
- JSON Formatter - format and validate the JSON responses your MCP server produces
- Swagger Viewer - explore the GitHub REST API spec before building tools against it
- cURL Command Generator - prototype API requests before writing Python handlers
- JWT Decoder - inspect GitHub personal access tokens to confirm their scopes
