Build Your Own MCP Server — The Most Complete Python Tutorial (2026)
Building your own MCP server lets you expose any data source or service to Claude and other MCP clients. This tutorial builds a complete server from scratch.
What We’re Building
A minimal MCP server that exposes two tools:
read_note(id)— Read a note by IDlist_notes()— List all notes
Install the SDK
pip install mcpComplete Server (Python)
# server.py — A complete MCP server in ~60 linesimport jsonimport asynciofrom pathlib import Pathfrom mcp.server import Serverfrom mcp.server.stdio import stdio_serverfrom mcp import types
# ── In-memory note store (replace with a real DB in production) ──────NOTES: dict[str, str] = { "1": "MCP stands for Model Context Protocol.", "2": "MCP uses JSON-RPC 2.0 over stdio or SSE.", "3": "Tools are functions; Resources are data.",}
# ── Server setup ──────────────────────────────────────────────────────app = Server("notes-server")
@app.list_tools()async def list_tools() -> list[types.Tool]: """Tell MCP clients what tools this server provides.""" return [ types.Tool( name="read_note", description="Read a note by its ID.", inputSchema={ "type": "object", "properties": { "id": {"type": "string", "description": "The note ID"} }, "required": ["id"], }, ), types.Tool( name="list_notes", description="List all available note IDs.", inputSchema={ "type": "object", "properties": {}, }, ), ]
@app.call_tool()async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: """Handle tool calls from MCP clients.""" if name == "read_note": note_id = arguments.get("id") note = NOTES.get(note_id) if note: return [types.TextContent(type="text", text=note)] else: return [types.TextContent(type="text", text=f"No note found with ID: {note_id}")]
elif name == "list_notes": note_list = "\n".join(f"- {k}: {v[:50]}..." for k, v in NOTES.items()) return [types.TextContent(type="text", text=f"Available notes:\n{note_list}")]
else: raise ValueError(f"Unknown tool: {name}")
# ── Run the server ─────────────────────────────────────────────────────async def main(): async with stdio_server() as (read_stream, write_stream): await app.run(read_stream, write_stream, app.create_initialization_options())
if __name__ == "__main__": asyncio.run(main())Register with Claude Code
Add to your .claude/settings.json or ~/.claude/settings.json:
{ "mcpServers": { "notes": { "command": "python", "args": ["/absolute/path/to/server.py"] } }}Test It
# Run the server directly to see what it exposespython server.pyOr use the MCP inspector (official debugging tool):
npx @modelcontextprotocol/inspector python server.pyThis opens a browser UI where you can call your tools manually.
Adding Resources
Resources expose data (not actions). The model can read them to get context.
@app.list_resources()async def list_resources() -> list[types.Resource]: return [ types.Resource( uri="notes://all", name="All Notes", description="Complete notes database", mimeType="application/json", ) ]
@app.read_resource()async def read_resource(uri: str) -> str: if uri == "notes://all": return json.dumps(NOTES, indent=2) raise ValueError(f"Unknown resource: {uri}")Error Handling Best Practices
@app.call_tool()async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: try: # ... your tool logic pass except KeyError as e: # Return a helpful error message (don't crash the server) return [types.TextContent(type="text", text=f"Missing required argument: {e}")] except Exception as e: return [types.TextContent(type="text", text=f"Tool error: {str(e)}")]Production Checklist
- Input validation (don’t trust
argumentsblindly) - Error handling (never crash the server process)
- Logging (write to stderr, not stdout — stdout is used by the protocol)
- Secrets via environment variables, not hardcoded
- Rate limiting if wrapping external APIs
What’s Next
- Browse Available Servers to see patterns in real servers
- The official MCP Python SDK has more examples