---
title: "Build an MCP server in Python with the official SDK"
description: "The SDK landscape in 2026 The official Python SDK ( mcp on PyPI) reached v2 alongside the 2026-07-28 spec. v2 supports every protocol revision, renames…"
url: https://optimizeall.com/learn/model-context-protocol-mcp/python-server-with-official-sdk
updated: 2026-10-05
---

Model Context Protocol (MCP): Connect AI to Your Tools and Data · Building MCP servers in Python and TypeScript · lesson 6 of 18 · 18 min

# Build an MCP server in Python with the official SDK

## The SDK landscape in 2026

The official Python SDK (`mcp` on PyPI) reached **v2** alongside the 2026-07-28 spec. v2 supports every protocol revision, renames the high-level server class to **`MCPServer`** (`from mcp.server import MCPServer`), and reworks several APIs. v1 (whose high-level class was `FastMCP`, imported from `mcp.server.fastmcp`) lives on a maintenance branch with security fixes. `pip install mcp` now installs 2.x, so pin `mcp>=1.28,<2` if you are not ready to migrate, and use the official migration guide when you are. The separate community project also called FastMCP continues independently; this lesson uses the official SDK.

Requirements: Python 3.10+. Install with the CLI extra: `uv add "mcp[cli]"` or `pip install "mcp[cli]"`.

## Anatomy of a high-level server

- `MCPServer(name, instructions=..., lifespan=...)` creates the server. `instructions` tell hosts how to use it.
- `@mcp.tool()` turns a type-hinted function into a tool. Type hints and Pydantic `Field` metadata become the input schema; the docstring becomes the description; a Pydantic return type becomes an output schema with structured content.
- `@mcp.resource("scheme://{param}")` registers a resource or template.
- `@mcp.prompt()` registers a prompt.
- `Context` (injected when a parameter is typed `ctx: Context`) gives access to request info, elicitation, progress, reading resources and lifespan state.
- `raise ToolError(...)` returns a model-readable error (`isError: true`); `MCPError` produces a protocol error.
- `mcp.run()` serves stdio; `mcp.run(transport="streamable-http", port=...)` serves HTTP (defaults to `127.0.0.1` and path `/mcp`).

## Managing resources with lifespan

Database pools and API clients should be created once, not per call. Use an async context manager as the **lifespan**; tools read it from `ctx.request_context.lifespan_context`.

## Worked example: a campaign-performance server for a PK/UAE agency

Requirements from the account team:

- Look up campaigns by client and market.
- Get performance for a date range (spend, impressions, clicks, conversions, CPA).
- Expose a glossary of metric definitions as a resource so the model explains metrics consistently.
- Provide a `/weekly-summary` prompt.
- All read-only; currency shown per market (PKR, AED).

## Hands-on: the full server

```python
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import date
from typing import Annotated, Literal

import asyncpg                                   # pip install asyncpg (or use sqlite3 for local tests)
from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

@dataclass
class AppCtx:
    pool: asyncpg.Pool

@asynccontextmanager
async def lifespan(server: MCPServer) -> AsyncIterator[AppCtx]:
    pool = await asyncpg.create_pool(os.environ["ANALYTICS_DSN"], min_size=1, max_size=5)
    try:
        yield AppCtx(pool=pool)
    finally:
        await pool.close()

mcp = MCPServer(
    "campaign-analytics",
    instructions="Read-only campaign analytics. Always state the date range and currency. "
                 "Use find_campaigns before get_performance.",
    lifespan=lifespan,
)

Market = Literal["PK", "AE", "SA", "UK", "US"]
READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)

class Campaign(BaseModel):
    campaign_id: str
    name: str
    channel: str
    market: Market

class CampaignList(BaseModel):
    campaigns: list[Campaign]
    total: int

class Performance(BaseModel):
    campaign_id: str
    start: date
    end: date
    currency: str
    spend: float
    impressions: int
    clicks: int
    conversions: int
    cpa: float | None = Field(description="Spend / conversions; null if no conversions")

@mcp.tool(title="Find campaigns", annotations=READ_ONLY)
async def find_campaigns(
    client_id: Annotated[str, Field(description="Client slug, e.g. 'noor-cosmetics'")],
    ctx: Context[AppCtx],
    market: Market | None = None,
    limit: Annotated[int, Field(ge=1, le=50)] = 20,
) -> CampaignList:
    """List a client's campaigns, optionally filtered by market. Returns IDs for get_performance."""
    pool = ctx.request_context.lifespan_context.pool
    rows = await pool.fetch(
        "SELECT id, name, channel, market FROM campaigns WHERE client_id=$1 "
        "AND ($2::text IS NULL OR market=$2) ORDER BY name LIMIT $3", client_id, market, limit)
    return CampaignList(campaigns=[Campaign(campaign_id=r["id"], name=r["name"], channel=r["channel"],
                                            market=r["market"]) for r in rows], total=len(rows))

@mcp.tool(title="Get performance", annotations=READ_ONLY)
async def get_performance(campaign_id: str, start: date, end: date, ctx: Context[AppCtx]) -> Performance:
    """Aggregate performance for one campaign between two dates (inclusive, max 92 days)."""
    if end < start or (end - start).days > 92:
        raise ToolError("Date range must be valid and at most 92 days; split longer ranges.")
    pool = ctx.request_context.lifespan_context.pool
    r = await pool.fetchrow(
        "SELECT c.currency, SUM(spend) spend, SUM(impressions) imp, SUM(clicks) clk, SUM(conversions) conv "
        "FROM daily_stats s JOIN campaigns c ON c.id=s.campaign_id "
        "WHERE s.campaign_id=$1 AND s.day BETWEEN $2 AND $3 GROUP BY c.currency", campaign_id, start, end)
    if r is None:
        raise ToolError(f"No data for {campaign_id} in that range. Check the ID with find_campaigns.")
    conv = int(r["conv"] or 0)
    return Performance(campaign_id=campaign_id, start=start, end=end, currency=r["currency"],
                       spend=float(r["spend"]), impressions=int(r["imp"]), clicks=int(r["clk"]),
                       conversions=conv, cpa=(float(r["spend"]) / conv) if conv else None)

@mcp.resource("glossary://metrics", mime_type="text/markdown")
def metrics_glossary() -> str:
    """Definitions of the metrics this server returns."""
    return ("- **CPA**: spend divided by conversions.\n- **CTR**: clicks divided by impressions.\n"
            "- Currency is the campaign's billing currency; never convert unless asked.")

@mcp.prompt(title="Weekly summary")
def weekly_summary(client_id: str) -> str:
    """Summarize last week's performance for a client."""
    return (f"For client {client_id}: call find_campaigns, then get_performance for last Monday to Sunday "
            "for each active campaign. Use the metrics glossary. Output a table and three insights.")

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

## Testing in memory

The v2 `Client` connects directly to your server object, no subprocess or port:

```python
import pytest
from mcp import Client
from server import mcp

@pytest.fixture
def anyio_backend():
    return "asyncio"

@pytest.mark.anyio
async def test_rejects_long_ranges():
    async with Client(mcp) as client:
        result = await client.call_tool("get_performance",
            {"campaign_id": "x", "start": "2026-01-01", "end": "2026-06-30"})
        assert result.is_error
```

(For tests that hit the database, provide a test DSN or refactor data access behind an interface you can fake.)

## Connecting it

`uv run mcp dev server.py` opens the Inspector. `uv run mcp install server.py` writes a Claude Desktop entry. For Claude Code: `claude mcp add campaign-analytics -- uv run --with "mcp[cli]" mcp run /absolute/path/server.py`. Use absolute paths; hosts launch servers from their own working directory with a minimal environment, so pass secrets such as `ANALYTICS_DSN` via the host's env configuration (e.g., `mcp install ... -v ANALYTICS_DSN=...` or `-f .env`).

## Pitfalls

- `print()` in a stdio server; use `logging` (stderr).
- Creating a DB connection per call instead of via lifespan.
- Unbounded queries; always cap rows and date ranges.
- Following v1 tutorials with v2 installed (or vice versa); check your installed major version.

## Measuring success

Unit-test coverage of tools, p95 latency per tool, error rate, and results size (tokens) per call.

## Video lecture: Build an MCP server in Python with the official SDK

Lecture coming soon · 15 chapters · about 9 minutes. Read the full transcript below.

1. Build a Python MCP server
2. Why the SDK matters
3. SDK v1 vs v2
4. Anatomy
5. Lifespan for shared resources
6. Simple example: convert_currency
7. Example: campaign analytics
8. Code walkthrough
9. Testing in memory
10. Connecting and pitfalls
11. Secrets for servers
12. Deeper: analytics self-service (illustrative)
13. Watch me do it: campaign-analytics
14. Try this now
15. Recap

## Lecture transcript

### Build a Python MCP server

In this lesson you'll build a real MCP server in Python, not a toy: a read only campaign analytics server with typed tools, structured outputs, a shared database pool, a resource, a prompt and tests. You'll also learn how the official SDK changed in version two, so old tutorials don't trip you up.

### Why the SDK matters

Why does the SDK matter as much as the protocol? Because nobody should hand write JSON RPC. A good SDK turns protocol details into ordinary Python: type hints become schemas, exceptions become errors the model can read, and a decorator becomes a tool. Think of it like a well designed kitchen appliance. You still need to know what you're cooking, but you don't need to build the oven. The trick is knowing which appliance you've got, because version one and version two have different buttons.

### SDK v1 vs v2

First, the SDK landscape. The official Python SDK, called mcp on PyPI, reached version two alongside the twenty twenty six spec. It supports every protocol revision, and its high level class is now called MCP Server. Version one used a class called Fast MCP, and it's still maintained with security fixes. Here's the trap: pip install mcp now gives you version two, so a version one tutorial will break. Either follow version two docs, or pin below two until you migrate with the official guide.

### Anatomy

The anatomy is pleasantly small. MCP Server creates the server, with a name and instructions for hosts. The tool decorator turns a type hinted function into a tool. Type hints and Pydantic field metadata become the input schema, the docstring becomes the description, and a Pydantic return type becomes an output schema with structured content. Resource and prompt decorators register the other primitives. A context parameter gives you request info, elicitation, progress and shared state. Raise tool error for failures the model should read. And run starts the server.

### Lifespan for shared resources

Shared resources, like database pools, should be created once. That's what the lifespan is for: an async context manager that opens the pool at start up, yields it, and closes it at shutdown. Each tool then reads the pool from the request context. Creating a new database connection inside every tool call is one of the most common performance mistakes in MCP servers.

### Simple example: convert_currency

A simple example before the big one. Write a tool called convert currency with three typed parameters: amount as a float, from and to as a literal choice of rupees, dirhams, riyals, pounds or dollars. Return a small Pydantic model with the converted amount and the rate used. Without writing any schema, the Inspector shows a form with a number field and two dropdowns, and the result comes back as structured data. That's the SDK doing the protocol work so you can focus on the logic, like where the exchange rate comes from.

### Example: campaign analytics

Our worked example is a campaign analytics server for an agency working across Pakistan and the UAE. Account teams need to find campaigns by client and market, get performance for a date range, including spend, impressions, clicks, conversions and cost per acquisition, and see figures in each market's own currency, like rupees or dirhams. It exposes a metrics glossary as a resource, so the model explains metrics consistently, and a weekly summary prompt. Everything is read only.

### Code walkthrough

Walk through the code. Market is a literal type, so the schema only accepts five market codes. Find campaigns caps results between one and fifty. Get performance rejects date ranges over ninety two days with a helpful tool error, so the model knows to split the range. Both tools return Pydantic models, so clients get structured, validated data. And both carry read only annotations as hints for hosts. The glossary resource and weekly summary prompt round it out.

### Testing in memory

Testing is easy in version two. The client class can connect directly to your server object in memory, with no subprocess and no port, a lot like a web framework's test client. The example test calls get performance with a six month range and asserts that the result is an error. For database backed tools, use a test database or put data access behind an interface you can fake.

### Connecting and pitfalls

To connect it, run mcp dev to open the Inspector, or mcp install to add it to Claude Desktop. For Claude Code, use claude mcp add with the launch command. Always use absolute paths, because hosts launch servers from their own folder with a minimal environment, and pass secrets like the database connection string through the host's environment configuration. Avoid print statements in standard I O servers, unbounded queries, and mixing version one tutorials with version two installs.

### Secrets for servers

A frequent question: how do I pass secrets like database passwords to a server that a desktop host launches? Not in the code, and not in a file you commit. Put them in the host's server configuration as environment variables, or reference a local secret store. For remote servers, load them from your platform's secret manager. And never accept secrets as tool arguments, because the model and the host logs would see them.

### Deeper: analytics self-service (illustrative)

Let's deepen the PK and UAE agency example with illustrative numbers. Account managers used to ask analysts for weekly figures, waiting half a day on average. With the campaign analytics server connected to Claude, they now ask directly: how did our Pakistan search campaigns do last week compared with the week before. The model calls find campaigns, then get performance, and explains CPA using the glossary resource, in rupees for Pakistan and dirhams for the UAE. Analysts reviewed a sample of answers for a month and found the numbers matched their dashboards. The ninety two day limit came up occasionally, and the tool error told the model to split the range, which it did without human help.

### Watch me do it: campaign-analytics

Watch me do it. Let's walk through the analytics server's key lines. The lifespan opens an asyncpg pool from an environment variable and closes it on shutdown. The server's instructions say to state the date range and currency, and to use find campaigns first. Market is a literal of five codes, so it becomes an enum. Find campaigns takes a client id with a description, the context, an optional market and a limit between one and fifty. It reads the pool from the request context and runs a parameterized query with dollar placeholders, then returns a campaign list model. Get performance first rejects bad or long date ranges with a tool error, then aggregates daily stats, and computes cost per acquisition only when there are conversions. I run the in memory test: a six month range comes back with is error true. Then I connect it to Claude Code with an absolute path and the DSN in the environment, and ask a real question.

### Try this now

Try this now. Build the campaign analytics server against a tiny local dataset; SQLite is fine. Add two in memory tests: one that checks a long date range returns an error, and one that checks the tool list includes find campaigns. Then connect it to one host with an absolute path and your database setting in the host's environment configuration. Ask the model a real question, like how did our UAE search campaigns do last week, and watch which tools it calls.

### Recap

To recap: the official Python SDK version two gives you typed tools with automatic schemas, structured outputs, lifespan managed resources, easy in memory tests and one command host setup. Your next step: build the campaign analytics server against a small local dataset, SQLite is fine, write two in memory tests, and connect it to one host.

## Key takeaways

- The official Python SDK v2 uses MCPServer; v1 used FastMCP, so pin versions and follow the matching docs.
- Type hints and Pydantic models generate input and output schemas automatically.
- Use a lifespan for shared resources like database pools and read them from the context.
- Raise ToolError for model-readable failures and cap rows and ranges in every query.
- Test tools in memory with Client(mcp) and connect hosts using absolute paths and env configuration.

## Try it

Build the campaign-analytics server against a small local dataset (SQLite is fine), write two in-memory tests, and connect it to one host.

- [Previous: Transports: stdio, Streamable HTTP and running at scale](https://optimizeall.com/learn/model-context-protocol-mcp/transports-stdio-streamable-http)
- [Next: Build an MCP server in TypeScript with the official SDK](https://optimizeall.com/learn/model-context-protocol-mcp/typescript-server-with-official-sdk)
- [All lessons of Model Context Protocol (MCP): Connect AI to Your Tools and Data](https://optimizeall.com/learn/model-context-protocol-mcp)
