By Himanshu Agarwal
If you've spent any time around AI engineering in the last year, you've probably heard the term MCP — Model Context Protocol — thrown around constantly. Maybe you've seen it in a GitHub README, in an Anthropic blog post, or in a Slack message from a colleague who just got a Claude-powered tool talking to their company's internal database in an afternoon. And if you're a Python developer, there's a good chance you've also wondered: where exactly does Python fit into all this, and why does it seem to be the language of choice for building MCP servers?
This article is a deep, practical answer to both questions. We'll walk through what MCP actually is, why it exists, how its architecture works, why Python has become such a natural fit for implementing it, and what advanced Python concepts you'll actually lean on once you start building real MCP servers instead of toy examples. By the end, you should have a working mental model good enough to start building — or to walk into an interview and talk about this stuff with real confidence.
Table of Contents
- The Problem MCP Was Built to Solve
- What Exactly Is MCP?
- The Core Architecture: Hosts, Clients, and Servers
- Tools, Resources, and Prompts — MCP's Three Primitives
- Transports: stdio vs Streamable HTTP
- Why Python Became the Default Language for MCP Servers
- Building Your First MCP Server in Python
- Advanced Python Patterns You'll Actually Use in MCP Servers
- Security, Auth, and Production Concerns
- MCP vs REST APIs vs Function Calling
- Where MCP Is Headed
- Final Thoughts
1. The Problem MCP Was Built to Solve
Before we talk about what MCP is, it's worth understanding the problem that made it necessary in the first place.
Large language models are extraordinarily capable at reasoning, writing, and understanding language — but on their own, they're isolated. A model has no access to your company's Jira board, your production database, your file system, or the weather API you want it to check before recommending an outfit. To be genuinely useful in real workflows, an AI application needs to reach outside itself and interact with the world: read files, query databases, call APIs, trigger automations.
For a long time, every AI application solved this problem in its own bespoke way. If you were building a chat assistant that needed to talk to GitHub, you wrote custom integration code specific to your application. If another team wanted their own assistant to talk to GitHub too, they wrote their own custom integration code, duplicating most of the same logic. Multiply this across dozens of applications and dozens of tools — Slack, Notion, Postgres, Salesforce, internal APIs — and you get what's sometimes called the M×N integration problem: M applications, each needing custom code for N tools, resulting in M times N discrete integration efforts.
This is exactly the problem that the Language Server Protocol solved for code editors and language tooling years earlier. Before LSP, every IDE had to write its own integration for every programming language's autocomplete, linting, and go-to-definition features. LSP standardized that interface once, and suddenly any LSP-compliant editor could talk to any LSP-compliant language server without custom glue code.
MCP does the same thing, but for AI applications and the tools/data they need to access. Instead of M×N bespoke integrations, you get M + N: an application builds one MCP client implementation, and a tool provider builds one MCP server implementation, and the two can talk to each other immediately, regardless of who built what.
2. What Exactly Is MCP?
The Model Context Protocol is an open, standardized protocol — originally introduced by Anthropic and now maintained as an open specification — that defines how AI applications connect to external context. That "context" comes in three flavors, which we'll cover in detail shortly: tools the model can invoke, resources it can read, and prompt templates a user can trigger.
At its core, MCP is a client-server protocol built on JSON-RPC 2.0, a lightweight, well-understood messaging format that has been used in developer tooling for years. An MCP server is a small, focused program that exposes some capability — say, the ability to query a Postgres database, search a codebase, or fetch weather data. An MCP client, embedded inside an AI application, connects to that server, discovers what it can do, and routes model or user requests to it.
The genuinely clever part of MCP's design is that servers are self-describing. When a client connects, it doesn't need pre-written documentation or hardcoded assumptions about what the server does — it asks the server directly, and the server responds with structured, machine-readable descriptions of every tool, resource, and prompt it offers, including full JSON Schema definitions for tool arguments. This is what lets a language model dynamically figure out which tool to call and how to call it correctly, without a human ever writing integration code specific to that server.
3. The Core Architecture: Hosts, Clients, and Servers
MCP's architecture has three distinct roles, and getting these straight is essential to understanding everything else.
The Host is the AI application itself — the thing the end user actually interacts with. This could be a chat interface, an IDE, an autonomous agent framework, or a command-line tool. The host owns the language model, manages the overall conversation, and is ultimately responsible for enforcing permissions and deciding which MCP servers are active at any given time.
The Client lives inside the host and manages a single, stateful connection to exactly one server. If a host wants to talk to three different MCP servers — say, one for GitHub, one for a database, and one for internal documentation — it spins up three separate client instances, each maintaining its own session, handshake state, and message routing.
The Server is an independent process, often in a completely different codebase or even a different programming language than the host, that actually implements some capability. A GitHub MCP server, for example, wraps GitHub's API and exposes operations like "list open issues" or "create a pull request" as discoverable tools.
This separation matters more than it might seem at first glance. Because servers are independent processes with a standardized interface, the same GitHub MCP server can be plugged into completely different AI applications built by completely different teams, without either side needing to know anything about the other's internal implementation. It also means a server can be written in Python while the host application is written in TypeScript, or vice versa — the protocol doesn't care, because everything happens over JSON-RPC.
When a client first connects to a server, they perform a handshake via an initialize request. The client declares which protocol version it supports and which optional capabilities it understands (such as sampling or roots, both covered below). The server responds with its own supported version and the capabilities it offers. From that point forward, both sides only use features the other side has explicitly agreed to support — which is what allows the protocol to evolve over time without breaking older implementations.
4. Tools, Resources, and Prompts — MCP's Three Primitives
Everything an MCP server exposes falls into one of three categories, and the distinction between them is genuinely useful, not just academic.
Tools are model-controlled — the language model itself decides when to call them, based on the conversation and the tool's description. A tool is essentially a function: it has a name, a natural-language description explaining what it does and when to use it, and an inputSchema written in JSON Schema describing its expected arguments. When the model decides a tool is relevant, the host sends a tools/call request with the arguments the model generated, the server executes the underlying logic, and the result flows back into the model's context. Good tool design is genuinely an art — vague descriptions or overly broad tools (a single tool that does five different things depending on a mode flag) tend to produce unreliable, hard-to-predict calls. Narrow, well-named, well-documented tools work dramatically better in practice.
Resources are application-controlled pieces of addressable data — think of them as the GET requests of MCP. Each resource has a URI (file:///project/notes.md, postgres://orders/12345, or a custom scheme entirely) and can be listed via resources/list and fetched via resources/read. Unlike tools, resources aren't meant to be "invoked" with arguments to trigger an action — they're meant to be read, much like static or semi-static context that a host might want to include in a conversation without the model needing to explicitly ask for it.
Prompts are user-controlled templates — reusable, parameterized interaction patterns that a human explicitly triggers, often surfaced as something like a slash command. A "summarize this support ticket" prompt template, for example, might take a ticket ID as a parameter and expand into a fully structured request the model can act on consistently, every time, regardless of how a given user might phrase the same request manually.
The distinction of who controls each primitive — model, application, or user — is the key design insight here. It maps cleanly onto how much autonomy you want to grant at each layer, and it's a distinction that shows up constantly once you start designing your own servers.
5. Transports: stdio vs Streamable HTTP
MCP is transport-agnostic at the message level — everything is JSON-RPC — but two transports dominate real-world usage.
stdio is used when the server runs as a local subprocess spawned directly by the host. Messages are exchanged over standard input and output streams. This is the simplest possible setup: no networking, no authentication layer, no TLS certificates to manage. It's ideal for local developer tools — a code editor spawning a filesystem-access server, for instance — where the server and host run on the same machine under the same user's permissions.
Streamable HTTP (which has largely superseded the earlier HTTP+SSE transport) is used when the server is remote — potentially serving many different users, running as an independently deployed and scaled service. This transport supports proper authentication (typically OAuth 2.1, including dynamic client registration and PKCE), horizontal scaling behind a load balancer, and long-lived streaming responses over a single HTTP connection, which matters for tool calls that take a while and benefit from sending incremental progress notifications rather than leaving the user staring at a blank spinner.
Choosing between the two is mostly a question of deployment topology. If you're building a personal productivity tool that runs entirely on your own machine, stdio is simpler and perfectly sufficient. If you're building a server meant to be used by many different users or organizations — something you'll deploy once and let others connect to remotely — Streamable HTTP with proper auth is the right call.
6. Why Python Became the Default Language for MCP Servers
If you look at the MCP ecosystem today, an outsized share of servers — official ones and community-built ones alike — are written in Python. This isn't an accident, and it's worth understanding why, especially if you're deciding what language to reach for on your own project.
First, Python is already the dominant language in the AI/ML ecosystem. The people building MCP servers are frequently the same people who already have Python-based data pipelines, ML models, or backend services. Wrapping an existing Python codebase's functionality as an MCP server is often a matter of adding a thin protocol layer on top of code that already exists, rather than a rewrite.
Second, Python's official MCP SDK, and specifically the FastMCP high-level API, dramatically reduces boilerplate. You can turn a plain Python function into a fully spec-compliant MCP tool with a single decorator, and the SDK automatically derives the JSON Schema from your type hints and docstring. This kind of ergonomic, decorator-driven API is a very natural fit for Python's existing conventions (think Flask, FastAPI, Click) and makes the barrier to writing your first server extremely low.
Third, Python's asyncio ecosystem maps cleanly onto MCP's I/O-heavy nature. Most MCP servers spend the overwhelming majority of their time waiting on I/O — database queries, HTTP calls to third-party APIs, file reads — rather than doing CPU-bound work. This is exactly the workload asyncio was designed for, and Python's async ecosystem (httpx, asyncpg, aiofiles, and so on) is mature enough that building a genuinely concurrent, well-behaved server doesn't require reinventing anything.
Fourth, Python's massive library ecosystem means almost any external system you want to wrap already has a well-supported client library. Whether you're building an MCP server around a SQL database, a cloud provider's API, or an internal REST service, chances are there's already a battle-tested Python package for it, meaning your MCP server can be a thin, reliable wrapper rather than something built from scratch.
None of this means Python is the only good choice — official SDKs also exist for TypeScript, Java, C#, and Kotlin, and plenty of production servers are written in those languages for good reasons (type safety, existing codebases, performance characteristics). But for prototyping quickly, for wrapping existing data/ML infrastructure, and for the sheer volume of available examples and community support, Python is very often the path of least resistance.
7. Building Your First MCP Server in Python
Let's make this concrete. Here's a minimal but genuinely functional MCP server using the official Python SDK's FastMCP interface:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather-server")
@mcp.tool()
def get_forecast(city: str) -> str:
"""Return a short weather forecast for a given city."""
# In a real server, this would call an actual weather API
return f"Sunny in {city}, 28°C"
@mcp.resource("config://settings")
def get_settings() -> str:
"""Expose current server configuration as a readable resource."""
return "units=metric;language=en"
if __name__ == "__main__":
mcp.run(transport="stdio")
A few things worth noticing here. The @mcp.tool() decorator does the heavy lifting: it inspects the function's type hints (city: str) to build a JSON Schema describing the expected arguments, and it uses the docstring as the tool's description — exactly the metadata a language model needs to decide when and how to call this tool. The @mcp.resource() decorator similarly exposes a readable piece of data under a URI scheme you define yourself.
On the client side, connecting to and calling this server looks like this:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
params = StdioServerParameters(command="python", args=["weather_server.py"])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
result = await session.call_tool("get_forecast", {"city": "Bengaluru"})
print(result)
This is genuinely the entire loop: spawn or connect to the server, perform the handshake with initialize(), discover what it offers, and issue calls. Everything about schema validation, JSON-RPC message framing, and request/response correlation is handled by the SDK underneath.
The gap between "this toy example" and "a production-grade server" is mostly about what you put inside the tool functions — proper error handling, input validation, authentication, logging, and the advanced Python patterns we'll cover next.
8. Advanced Python Patterns You'll Actually Use in MCP Servers
Once you move past hello-world examples, a handful of advanced Python concepts show up again and again in real MCP server code.
Async all the way down. Because most tool implementations spend their time waiting on network or disk I/O, you'll want your tool functions to be async def and use async-native libraries (httpx.AsyncClient instead of requests, asyncpg instead of a blocking Postgres driver). A single blocking call inside an async tool function can stall the entire event loop, silently degrading every other concurrent request the server is handling.
Context managers for resource lifecycle. Database connections, HTTP client sessions, and file handles all benefit from proper async with context management, both for correctness and for making sure connections are cleaned up even when a tool call raises an exception midway through.
import httpx
from contextlib import asynccontextmanager
@asynccontextmanager
async def http_client():
client = httpx.AsyncClient(timeout=10.0)
try:
yield client
finally:
await client.aclose()
Pydantic models for tool input validation. While FastMCP derives basic JSON Schema from type hints automatically, real-world tools often benefit from explicit Pydantic models — you get richer validation (value ranges, custom validators, nested structures) and much clearer error messages when a model-generated call doesn't quite match the expected shape.
Structured error handling that surfaces useful information to the model. A common mistake is letting an unhandled exception propagate and crash the tool call entirely. Instead, catch expected failure modes and return a clear, actionable error message as part of the tool result — this lets the model see exactly what went wrong and decide whether to retry, adjust its approach, or ask the user for clarification.
@mcp.tool()
async def query_database(sql: str) -> str:
"""Run a read-only SQL query against the analytics database."""
try:
rows = await run_query(sql)
return format_rows(rows)
except QuerySyntaxError as exc:
return f"Error: invalid SQL syntax — {exc}"
except PermissionError:
return "Error: this query touches a restricted table."
Decorators for cross-cutting concerns. Logging, rate limiting, retries, and permission checks all tend to repeat across many tools in a real server, which makes them natural candidates for decorators layered on top of (or alongside) @mcp.tool().
import functools
import logging
def logged(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
logging.info(f"Calling {func.__name__} with {kwargs}")
result = await func(*args, **kwargs)
logging.info(f"{func.__name__} returned successfully")
return result
return wrapper
Dataclasses for structured internal state. Servers that maintain any kind of session or cached state benefit from dataclasses over loose dictionaries — you get type safety, auto-generated __repr__ for debugging, and a much clearer contract for what data a given piece of state actually holds.
Careful use of functools.lru_cache for expensive, pure computations — but only for genuinely pure, deterministic operations, since caching something that depends on external state (like a live API call) will silently serve stale data.
None of these are exotic techniques — they're standard, well-understood Python practices. What's notable is how consistently they show up once you move from "toy tool that returns a hardcoded string" to "tool that talks to a real database under real concurrent load."
9. Security, Auth, and Production Concerns
It's worth spending a moment on security, because MCP servers occupy an unusual trust position: they run with whatever permissions the host grants them, and their output — tool descriptions, resource content, error messages — ultimately becomes part of the model's context.
This means a poorly designed or malicious server can attempt something like prompt injection: crafting tool descriptions or returned data in a way that tries to manipulate the model's subsequent behavior. The practical defenses are the same ones you'd apply to any system handling untrusted input: validate and sanitize everything server-side rather than trusting arguments the model generates, apply the principle of least privilege to whatever credentials the server holds, use MCP's "roots" feature to scope filesystem access to only what's actually needed, and require explicit user confirmation before executing anything destructive (deleting data, sending emails, making purchases).
For remote, HTTP-based servers, authentication should go through the OAuth 2.1 flow MCP specifies, obtaining short-lived, appropriately scoped tokens rather than baking long-lived API keys directly into server configuration — a mistake that significantly increases blast radius if that configuration ever leaks.
On the operational side, production MCP servers benefit from the same discipline as any backend service: structured logging of tool calls (with sensitive fields redacted), rate limiting to prevent runaway agentic loops from hammering downstream systems, keeping session state external (Redis, a database) rather than in local process memory so the service can scale horizontally, and explicit versioning so hosts can pin to a known-good schema as the server evolves.
10. MCP vs REST APIs vs Function Calling
A question that comes up constantly: if MCP is essentially a standardized way to expose functions and data, how is it different from a REST API, or from the native "function calling" features most LLM APIs already support?
A traditional REST API is built for a human developer to read documentation and hand-write integration code against fixed endpoints. There's no built-in mechanism for a client to dynamically discover what's available or how to call it — that knowledge lives in documentation, external to the API itself.
Native function calling, offered directly by most LLM APIs, lets a single application define tools for that specific model to call — but that tool definition is tied entirely to that one codebase. If another application wants the same functionality, it has to reimplement the tool definitions and the underlying logic from scratch.
MCP sits in between these, solving the discovery problem REST lacks and the portability problem proprietary function calling lacks. A client can ask a server what it offers at runtime and receive machine-readable schemas the model can act on directly — no documentation-reading required, and no per-application reimplementation. The same server can be plugged into any MCP-compliant host, regardless of who built it. MCP also standardizes bidirectional capabilities like sampling (a server requesting a model completion) and change notifications, which fall outside the scope of what either REST or basic function calling addresses.
11. Where MCP Is Headed
MCP is still a young protocol, and it's evolving quickly. A few trends worth watching: growing adoption of the Streamable HTTP transport for remote, multi-tenant servers as more companies expose official MCP servers for their products; increasing standardization around authentication and enterprise-grade access control; a growing public registry of community-built servers spanning databases, SaaS tools, and developer platforms; and continued refinement of features like sampling and roots as more hosts implement the full specification rather than just the basics.
For Python developers specifically, this means the ecosystem of async-native client libraries, SDK ergonomics, and tooling (like the MCP Inspector for interactively testing servers) is likely to keep maturing quickly — which is good news if you're getting in now, since the tooling gap between "hello world" and "production-ready" keeps shrinking.
12. Final Thoughts
MCP represents a genuinely useful shift in how AI applications connect to the outside world — replacing a mess of bespoke, one-off integrations with a single, self-describing protocol that any compliant host and server can speak. Python's combination of a mature async ecosystem, an enormous library surface for wrapping existing systems, and a low-friction official SDK has made it one of the most natural languages to build MCP servers in, which is exactly why so much of the ecosystem's early growth has happened there.
If you're a Python developer looking to get genuinely hands-on with this — whether for a side project, for work, or to walk into interviews with real depth instead of surface-level buzzwords — building a couple of real MCP servers yourself is by far the fastest way to internalize all of this. Start small: wrap one API you already use, add proper error handling and async I/O, and go from there.
Want to Go Deeper? Get the Full Interview Prep Guide
If this article gave you a solid foundation, the next step is testing that understanding against real interview-style questions — the kind that come up when you're being evaluated for a role that touches AI tooling, agentic systems, or advanced Python engineering.
50 Advanced MCP & Python Interview Questions and Answers is a focused PDF guide covering everything discussed here in much greater depth — 25 questions on MCP architecture, transports, security, and production deployment, and 25 questions on advanced Python: the GIL, asyncio internals, descriptors, metaclasses, memory management, concurrency, testing, and more. Every answer includes real, working code examples, not just textbook definitions.
👉 Download it here: https://himanshuai.gumroad.com/l/50AdvancedMCPPythonInterviewQuestionsandAnswers
Written by Himanshu Agarwal.
Top comments (0)