Build a custom MCP server in 50 lines of Python

· Tutorials

A working MCP server in 50 lines of Python — annotated, registered, ready to extend. The full setup with troubleshooting for common errors.

You don't need 500 lines of boilerplate to ship a working MCP server. Here's one in 50 lines of Python that exposes a tool to Claude Code — annotated, registered, and ready to extend.

What we're building

A MCP server that exposes a single tool: getweather(city) returns fake weather data. Trivial, but it covers the full loop — server lifecycle, tool registration, request/response, and Claude Code integration. Once you understand this, every other MCP server is a variation.

The 50 lines (annotated)

server = Server("weather")

@server.listtools() async def listtools() - list[Tool]: return [ Tool( name="getweather", description="Get fake weather for a city. Useful for demos.", inputSchema={ "type": "object", "properties": { "city": {"type": "string", "description": "City name"} }, "required": ["city"], }, ) ]

@server.calltool() async def calltool(name: str, arguments: dict) - list[TextContent]: if name == "getweather": city = arguments.get("city", "unknown") # Replace with real API call later return [TextContent( type="text", text=json.dumps({"city": city, "tempc": 22, "condition": "sunny"}) )] raise ValueError(f"Unknown tool: {name}")

async def main(): async with stdioserver() as (read, write): await server.run(read, write, server.createinitializationoptions())

if name == "main": asyncio.run(main())

That's the entire server. Two functions: listtools tells Claude what tools exist, calltool handles the actual invocation. The stdio transport handles the wire protocol — you don't touch JSON-RPC directly.

Install + run

The server listens on stdio, waiting for Claude Code to send it JSON-RPC messages. It won't print anything by default — that's correct.

Register it with Claude Code

Add to your project's .mcp.json:

Restart Claude Code. Type: "What's the weather in Mumbai?" Claude will see the getweather tool, decide it's relevant, call it, and respond with the JSON.

Extending it — adding a real API

Replace the fake return with a real call:

@server.calltool() async def calltool(name: str, arguments: dict) - list[TextContent]: if name == "getweather": city = arguments.get("city", "") url = f"https://wttr.in/{city}?format=j1" with urllib.request.urlopen(url) as r: data = json.loads(r.read()) temp = data["currentcondition"][0]["tempC"] return [TextContent(type="text", text=json.dumps({"city": city, "tempc": int(temp)}))] raise ValueError(f"Unknown tool: {name}")

Now you have a real weather tool. The same pattern works for any HTTP API — Stripe, GitHub, your internal service.

Common mistakes

Mistake 1: Printing to stdout

The stdio transport uses stdout for JSON-RPC. Any stray print() corrupts the stream. Use logging instead:

Mistake 2: Blocking calls in async functions

time.sleep blocks the event loop. Use await asyncio.sleep(5) for delays, or asyncio.tothread() for blocking calls like requests.get().

Mistake 3: Not validating inputSchema

Claude will occasionally send malformed arguments. Validate:

Verdict

50 lines, one tool, working in under 10 minutes. Every MCP server you'll write follows this skeleton — swap the tool list and the call handler. If you want to go deeper, see my MCP servers ranked list for what's worth installing, and the Claude Code memory MCP setup for a more complex real-world server.

Want me to review your MCP server code? Drop it on the contact page — happy to look at anything Python or TypeScript.