Model Context Protocol (MCP): Connect AI to Your Tools and DataBuilding MCP servers in Python and TypeScript · Lesson 6 of 18
Build an MCP server in Python with the official SDK
Video lecture
Build an MCP server in Python with the official SDK
The narrated lecture is in production
Every chapter is scripted and ready. Browse the chapters and read the full transcript now — the video will appear here when it’s published.
Chapters
Transcript of the narration, chapter by chapter.
0:00 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.
0:23 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.
1:00 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.
1:37 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.
2:16 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.
2:43 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.
3:23 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.
3:56 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.
4:30 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.
5:00 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.
5:34 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.
6:06 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.
6:57 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.
8:08 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.
8:44 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.
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.instructionstell hosts how to use it.@mcp.tool()turns a type-hinted function into a tool. Type hints and PydanticFieldmetadata 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 typedctx: Context) gives access to request info, elicitation, progress, reading resources and lifespan state.raise ToolError(...)returns a model-readable error (isError: true);MCPErrorproduces a protocol error.mcp.run()serves stdio;mcp.run(transport="streamable-http", port=...)serves HTTP (defaults to127.0.0.1and 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-summaryprompt. - All read-only; currency shown per market (PKR, AED).
Hands-on: the full server
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:
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; uselogging(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.
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.
Check your understanding
Quick questions to lock in the lesson. They don’t count towards your certificate.
Put it into practice
Build the campaign-analytics server against a small local dataset (SQLite is fine), write two in-memory tests, and connect it to one host.
Enrol for free to save your progress
Reading is always free. Enrol to keep your place, take the final assessment and earn a verifiable certificate.