A technical breakdown of what you're really building when you build an MCP server
The Model Context Protocol gets described in some fairly grand terms: "USB-C for AI applications," a universal adapter for connecting models to the world. That framing is useful for explaining why MCP matters, but it undersells how mundane the thing actually is once you open it up. An MCP server is not an AI. It has no model, no reasoning, no judgment. It's a small RPC service that answers a fixed set of questions ("what can you do," "do this thing") over a rigid handshake. Building one is closer to writing a well-behaved subprocess than building anything resembling intelligence.
This is a look at what's actually inside that subprocess: the handshake, the wire format, the code you write, and the places the whole thing tends to break in practice.
The three roles, briefly
We covered this in more depth in our piece on agent architecture, but it's worth restating here because MCP formalizes exactly this split. There are three participants: a host (the AI application, like a desktop agent or an IDE), a client (a connector object the host creates, one per server, that owns a single dedicated session), and a server (the actual program exposing capabilities). The host is where the model lives. The client and server are pure plumbing.
A host typically manages several clients at once, one per connected server. Local servers (filesystem access, a database, a custom tool) usually talk over standard input and output on the same machine. Remote servers (a SaaS product's own MCP endpoint) talk over HTTP. Either way, the client's only job is to hold that connection open and shuttle typed messages back and forth.
The wire: JSON-RPC and a fixed lifecycle
Underneath everything, MCP is JSON-RPC 2.0: requests with an id, responses that echo that id, and notifications that expect nothing back. What makes it MCP specifically is the fixed sequence every connection goes through.
Step one is the handshake. Before anything else happens, client and server negotiate a protocol version and declare what they support:
// Client -> Server
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": { "elicitation": {} },
"clientInfo": { "name": "example-client", "version": "1.0.0" }
}
}
// Server -> Client
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": { "tools": { "listChanged": true }, "resources": {} },
"serverInfo": { "name": "example-server", "version": "1.0.0" }
}
}
If the versions aren't compatible, the connection is supposed to terminate rather than limp along guessing. Once initialization succeeds, the client sends a plain notification (no id, no response expected) saying it's ready, and only then does real work start.
Step two is discovery. The client asks what's available:
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }
and the server answers with an array of tool definitions: name, description, and a JSON Schema for the input. This is the exact material that gets handed to the model as its menu of options; nothing more sophisticated happens here than "here is a list of functions and their shapes."
Step three is execution, which looks exactly like you'd expect from any RPC call:
{
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": { "name": "weather_current", "arguments": { "location": "San Francisco" } }
}
The server runs the underlying function and returns a content array (text, images, or structured data) as the result. That result gets folded back into the model's conversation on the host side. The server has no idea what happens to its output after that; it just answers the call.
What building one actually looks like
Strip away the protocol theory and building an MCP server is writing ordinary functions and pointing a framework at them. Here's a real, complete example using the official Python SDK's FastMCP class:
from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather")
NWS_API_BASE = "https://api.weather.gov"
async def make_nws_request(url: str) -> dict[str, Any] | None:
"""Make a request to the NWS API with proper error handling."""
async with httpx.AsyncClient() as client:
try:
response = await client.get(url, timeout=30.0)
response.raise_for_status()
return response.json()
except Exception:
return None
@mcp.tool()
async def get_forecast(latitude: float, longitude: float) -> str:
"""Get weather forecast for a location.
Args:
latitude: Latitude of the location
longitude: Longitude of the location
"""
points = await make_nws_request(f"{NWS_API_BASE}/points/{latitude},{longitude}")
if not points:
return "Unable to fetch forecast data for this location."
forecast = await make_nws_request(points["properties"]["forecast"])
periods = forecast["properties"]["periods"][:5]
return "\n---\n".join(f'{p["name"]}: {p["detailedForecast"]}' for p in periods)
if __name__ == "__main__":
mcp.run(transport="stdio")
Notice what's doing the work here: the @mcp.tool() decorator, the type hints, and the docstring. FastMCP reads all three and automatically generates the JSON Schema that gets returned from tools/list. There's no separate schema file to maintain and no manual JSON-RPC wiring in this code at all; the SDK's decorator layer handles the entire protocol lifecycle and just calls your function when a tools/call request comes in with that name. This is also, concretely, why tool descriptions matter so much: your docstring isn't documentation for a future developer, it's the literal text the model reads to decide whether and how to call your function.
Wiring it up: transports and how a host finds a server
For a local server, a host doesn't discover it by magic; someone has to tell the host what to run. Claude Desktop, for instance, reads a config file and spawns your server as a subprocess:
{
"mcpServers": {
"weather": {
"command": "uv",
"args": ["--directory", "/absolute/path/to/weather", "run", "weather.py"]
}
}
}
The host launches that command, and communication happens entirely over the process's stdin and stdout using the stdio transport: no network involved, no ports, just pipes between two processes on the same machine. This is fast and has zero network overhead, but it also means exactly one client ever talks to that server instance (the one that spawned it).
Remote servers work differently. They speak Streamable HTTP, which means a single server process can serve many clients at once, over the network, using standard HTTP authentication (bearer tokens, API keys, OAuth). That difference (one dedicated local process vs. one shared remote service) is the main architectural fork in MCP server design, and it's usually decided by what you're exposing: a database on your laptop is a stdio server, a SaaS company's public integration is an HTTP server.
The gotcha that catches almost everyone once
Here's a detail that sounds trivial and breaks real servers constantly: a stdio server must never write anything to standard output except protocol messages. The transport is stdout. If your code calls a stray print() for debugging, that text lands in the same stream as the JSON-RPC responses, the client fails to parse it, and the connection breaks in a way that looks nothing like the actual bug. The fix is mechanical: log to stderr, or use a logging library configured to do the same, and never touch stdout except through the SDK. It's the kind of failure mode that only exists because of how literally the transport layer treats the process's own output streams, and it's a good reminder that "the protocol" and "your code" are sharing physical plumbing, not just a conceptual boundary.
Beyond tools: resources, prompts, and who can ask whom
Tools aren't the only thing a server can expose. Resources are file-like, addressable data (a file's contents, a database record) that a host can read into context without necessarily invoking any logic. Prompts are reusable templates, effectively pre-packaged few-shot examples or instructions a server author thinks the model will use well.
MCP also defines primitives that flow the other direction, from server to client rather than client to server. Elicitation lets a server pause and ask the host to collect more information from the user mid-task, which is the actual mechanism behind an agent stopping to ask a clarifying question instead of guessing. Sampling lets a server request a model completion through the host's own model connection, so a tool author can get a bit of LLM judgment inside their server without bundling a separate model or API key. Both are still just JSON-RPC messages; neither requires the server to have any intelligence of its own; they just give the server a standard way to borrow the host's.
The part the protocol doesn't solve for you: trust
MCP defines message shapes and lifecycles. It does not, by itself, make a server safe to run. For local stdio servers, the protocol has nothing to say about sandboxing: your server runs with whatever permissions the process that spawned it has, and if it can read your filesystem, it can read all of it, unless the host or the operating system restricts that separately. For remote HTTP servers, the current specification leans hard on established standards rather than inventing new ones: servers are expected to act as OAuth 2.1 resource servers, validating bearer tokens (ideally via local JWT signature and expiry checks with a short-lived cached key set, reserving full introspection calls for the sensitive operations) rather than rolling custom authentication. The protocol's authorization model is deliberately boring: PKCE-protected OAuth flows, resource indicators binding a token to the specific server it was issued for, and standard discovery documents so a client knows where to send a user to log in. None of that is exotic; it's the same auth machinery most web APIs already use, applied here because MCP explicitly declined to invent its own.
The practical upshot: writing an MCP server is not writing a security boundary. It's writing a function, wrapping it in a well-known handshake, and then relying on entirely separate, well-understood systems (process permissions, OAuth, token scopes) to decide who's allowed to call it and with what authority.
The takeaway
An MCP server is a small program that answers three kinds of questions ("what do you have," "do this thing," "here's more context") in a strict, versioned JSON-RPC dialect, over either a local pipe or an HTTP connection. Frameworks like FastMCP make writing one feel like decorating ordinary functions, and that's not an oversimplification, it's genuinely most of the job. The interesting engineering is in the same places it always is with RPC systems: getting the schema right so the caller (in this case, a model) can use it correctly, keeping the transport clean, and treating authorization as someone else's well-tested problem rather than reinventing it. MCP's real contribution isn't a clever new idea; it's making that unglamorous plumbing consistent enough that any host can talk to any server without custom integration work.
Further reading:
- Model Context Protocol, Architecture Overview
- Model Context Protocol, Build an MCP Server
- Model Context Protocol, Authorization Specification
This piece is part of our ongoing research into how agentic AI systems are actually built and where their behavior comes from.


