← Back to Blog

Build Your First MCP Server in Python

Every AI app you wire up needs its own glue. A wrapper for Claude, another for ChatGPT, a third for your internal agent. Each one redefines the same tools, re-parses the same arguments, and breaks when the model's API changes. MCP (Model Context Protocol) removes that layer: you build the server once, and any MCP-capable host can discover your tools, read your resources, and call everything through one standard interface.

MCP is an open protocol that Anthropic released in November 2024 and now develops with a broader working group. Underneath it is plain JSON-RPC 2.0. It defines three primitives:

  • Tools: functions the model can call with typed arguments
  • Resources: read-only data you expose by URI
  • Prompts: reusable templates for common tasks

Two transports matter in practice. stdio runs the server as a local subprocess, which is how desktop hosts launch it. Streamable HTTP serves it over a real HTTP port for anything remote. The spec uses date-based versions; the current revision is 2026-07-28, and the Python SDK's v2 line targets it.

Prerequisites

  • Python 3.10+
  • uv or pip
  • Node.js with npx for the MCP Inspector (only needed in Step 4)

You don't need an API key or a specific chat app. Everything in this tutorial runs locally.

Step 1: Install the SDK

pip install "mcp[cli]"

or, with uv:

uv add "mcp[cli]"

The [cli] extra adds the mcp command-line tool (mcp dev, mcp run, mcp install) on top of the SDK.

One warning before you copy code from older tutorials: pip install mcp now installs v2, where the server class was renamed. The old import, from mcp.server.fastmcp import FastMCP, is gone rather than deprecated. This article uses v2 throughout.

Step 2: Write server.py

Create a file called server.py with all three primitives so you can see how each one looks:

from mcp.server import MCPServer

mcp = MCPServer("Kitchen")

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b

@mcp.tool()
def count_words(text: str) -> int:
    """Count the words in a piece of text."""
    return len(text.split())

@mcp.resource("greeting://{name}")
def greeting(name: str) -> str:
    """Greet someone by name."""
    return f"Hello, {name}!"

@mcp.prompt()
def review(code: str) -> str:
    """Ask the model to review a code snippet."""
    return f"Review this code for bugs and style: {code}"

if __name__ == "__main__":
    mcp.run()

Notice what you did not write: no JSON Schema, no request parsing, no protocol handling. Your type hints become the tool's input schema, and the docstring becomes the description the model reads when deciding whether to call the tool. That is the whole point of the SDK: two functions and a docstring are a complete integration surface.

Step 3: Run it over stdio

python server.py

Nothing prints, and the process does not return. That is correct behavior. stdout is the wire: the server speaks JSON-RPC over standard input and output, and it waits for a host to say something first. If you print() inside a tool during service, the SDK diverts flushed output to stderr so it can't corrupt the stream. For logs you actually want to see, use the logging module.

Step 4: Test it with the MCP Inspector

uv run mcp dev server.py

This launches your file as a subprocess over stdio and opens a web UI, exactly the same way a real host would launch it. The Inspector needs npx on your PATH. From there you can list tools, inspect the generated schemas, call add with arguments, and read the greeting resource.

You never gave it a port, because there isn't one. stdio is the transport.

Step 5: Register it with a host

To make Claude Desktop launch the server for you in every conversation:

uv run mcp install server.py --name "Kitchen"

The host starts the server in its own process, so your shell environment is not inherited. Record the environment variables the server needs with -v KEY=VALUE or -f .env. Other hosts, like Claude Code, Cursor, and VS Code, accept the same launch command in their own MCP config files; each project documents its format.

Step 6: Serve it over HTTP

The same server, unchanged, can answer over Streamable HTTP. Change the bottom of the file:

if __name__ == "__main__":
    mcp.run(transport="streamable-http", port=3001)

Clients now connect to http://127.0.0.1:3001/mcp. Or skip the edit entirely and let the CLI run it:

uv run mcp run server.py --transport streamable-http

One trap here: transport options belong to run(), not to the constructor. MCPServer("Kitchen", port=3001) raises a TypeError. The constructor describes what the server is; run() describes how it is served.

Step 7: Write a real test

The SDK includes a client, so you can test the server in memory without any subprocess or port:

import pytest
from mcp import Client

from server import mcp

@pytest.mark.anyio
async def test_add() -> None:
    async with Client(mcp) as client:
        result = await client.call_tool("add", {"a": 1, "b": 2})
        assert result.structured_content == {"result": 3}

Client(mcp) connects to the server object directly, no transport involved. This is the pattern the official docs use for every example in their test suite, and it is the fastest way to keep your tools honest as they grow.

stdio vs Streamable HTTP

stdio Streamable HTTP
How it runs local subprocess over stdin/stdout HTTP server on a port
Default yes no
Best for desktop hosts, same machine deployed or remote servers
Auth not needed OAuth when you need it

Start with stdio. Move to Streamable HTTP when another machine, a browser client, or multiple hosts need to reach the server.

Common traps

  • The FastMCP confusion. Two unrelated things are called FastMCP. The v1 SDK exposed FastMCP from mcp.server.fastmcp, renamed to MCPServer in v2, and the old import path is gone. A separate third-party package, also named fastmcp, has its own decorator syntax. If a snippet raises ImportError, check which of the three it targets before changing your code.
  • The if __name__ == "__main__": guard is not optional. mcp dev, mcp run, mcp install, and your tests all import the file first. Without the guard, importing the file starts a server.
  • Pin legacy dependencies. If a project you depend on still targets v1, keep mcp>=1.28,<2 in its requirements so an unpinned resolve stays on the 1.x line, which still receives security fixes.

When MCP makes sense (and when it doesn't)

MCP earns its place when the consumer is a model or an agent host that benefits from discovery: hosts can list your tools, read their schemas, and call them without any per-host integration code on your side. One server, every host.

A plain REST API is the right call when you have one consumer and the extra protocol layer buys you nothing. The good news is these are not mutually exclusive: an MCP tool can simply call a function that wraps your existing API. Many production servers are exactly that, a thin MCP layer over an internal service.

Next steps

The SDK docs cover what comes after the basics: handler dependencies (the Resolve pattern for asking the user mid-call), resources with security options, and OpenTelemetry tracing that ships enabled by default. Then pick a real problem to expose. The best first project is your own data: a server that exposes your notes, your project board, or your internal API, and connect it to the host you already use.

References:

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation