The Model Context Protocol is an open standard for connecting AI applications to external tools and data through one interface instead of a custom integration per pair. Anthropic published it in November 2024 and released it under an open specification with SDKs in more than a dozen languages. The problem it solves is combinatorial. Before MCP, every AI application needed bespoke glue for every data source, so five applications and ten tools meant fifty separate integrations. MCP collapses that to fifteen: write each tool once as a server, write each app's client once, and any client can drive any server.
Mechanically: an MCP server publishes three capabilities, tools (functions the model can call), resources (data the app can read into context), and prompts (templates a user can invoke). An MCP client lives inside a host application such as a desktop assistant, an IDE, or your own agent, holding one connection per server. The two sides exchange JSON-RPC 2.0 messages over one of two transports: stdio for a local subprocess, Streamable HTTP for anything remote. The model never speaks to a server directly. The host calls the tool and puts the result into the model's context.
Key Takeaways
- MCP is a transport and packaging standard, not a model feature. Function calling still does the choosing; MCP moves definitions and results between processes.
- Two transports, one decision. stdio for local servers with filesystem or shell access, Streamable HTTP for remote and multi-user servers.
- A minimal working server is roughly twenty lines in the Python or TypeScript SDK, and the Inspector runs it without any host app.
- Skip MCP when one application calls one integration you own. The indirection buys reuse, and reuse is the only thing it buys.
This guide walks the whole path: architecture, primitives, both transports, a working server in Python and TypeScript, wiring it into three clients, the failures every beginner hits, the authorization model the spec requires, production concerns, and an honest comparison against the alternatives.
What MCP Is and What Problem It Solves
The useful analogy is a hardware port. USB did not make mice better; it made every mouse work in every computer. MCP does not make a model smarter at using your database. It makes your database wrapper work in every MCP-capable client without a rewrite. That matters most when the tool author and the app author are different people: a company that wants its issue tracker reachable from any assistant ships one server instead of a plugin per platform.
The specification is versioned by date rather than semantic version. The first revision was 2024-11-05. Later revisions replaced the original HTTP transport with Streamable HTTP, added structured tool output, and defined the authorization model around OAuth 2.1. Clients and servers negotiate a shared version during the handshake, so a newer client can still talk to an older server. Check modelcontextprotocol.io/specification for the revision currently in force before building against a feature.
The Client/Server Architecture
Three roles matter, and confusing them causes most early frustration. The host is the application the user interacts with. The client is a connector object inside that host, one per server, owning a single stateful session. The server is a separate program exposing capabilities. The model sits inside the host and never has network access to a server.
The one-client-per-server rule is deliberate. Every connection gets an isolated session, capability set, and lifetime, so a crashed or malicious server cannot see another server's traffic. A host running five servers holds five clients.
Every session opens with a handshake: the client sends initialize with the protocol version and capabilities it supports, the server replies with what it agrees to, and the client sends an initialized notification. Only then may either side send anything else. Capability negotiation is what lets the protocol grow without breaking older peers, since a client never calls resources/list on a server that did not advertise resources.
// client → server
{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": { "sampling": {}, "roots": { "listChanged": true } },
"clientInfo": { "name": "my-host", "version": "1.0.0" } } }
// server → client
{ "jsonrpc": "2.0", "id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": { "tools": { "listChanged": true }, "resources": {} },
"serverInfo": { "name": "weather", "version": "1.0.0" } } }
// client → server (notification, no id, no reply)
{ "jsonrpc": "2.0", "method": "notifications/initialized" }
After that it is ordinary JSON-RPC: requests that expect a response (tools/list, tools/call, resources/read) and notifications that do not (progress updates, log messages, notifications/tools/list_changed). The session is stateful and bidirectional, so a server can push a change notification and the client refreshes without polling.
The Primitives: Tools, Resources, and Prompts
Servers expose three primitives, and the distinction between them is about who decides to use them. Tools are model-controlled. Resources are application-controlled. Prompts are user-controlled. Getting this wrong produces servers that technically work but behave badly in real clients.
Tools (model-controlled)
Functions with a JSON Schema for inputs. The model reads the description, decides to call one, and emits arguments. Anything with a side effect belongs here, behind approval.
Resources (app-controlled)
Addressable read-only data identified by URI, such as file:///notes.md or db://users/42. The host decides what to pull in. No side effects, ever.
Prompts (user-controlled)
Named, parameterized templates the user picks explicitly, typically surfaced as a slash command or menu item. Good for encoding a workflow the server author knows best.
Client features (server-requested)
The connection runs both ways. Sampling asks the host to run a completion. Roots tell a server which directories it may touch. Elicitation asks the user a question mid-call.
Sampling is the one people underuse. A server can request an LLM completion without shipping its own API key or model choice: the host runs it with its own credentials and approval policy, then hands the text back. A summarization server written this way costs its author nothing to operate.
Tool descriptions are load-bearing. The model chooses almost entirely from the name and description, so say when to call the tool, not just what it does. "Search the customer database. Call this when the user mentions an account, order, or invoice by number" beats "Searches the DB" by a wide margin. The structured output guide covers the schema half of the problem.
Transports: stdio vs Streamable HTTP
MCP defines two standard transports. stdio launches the server as a child process and speaks JSON-RPC over its standard input and output. Streamable HTTP exposes a single HTTP endpoint that accepts POSTed JSON-RPC and can upgrade a response to a Server-Sent Events stream when the server needs to push messages back. An older HTTP-plus-SSE transport using two separate endpoints was replaced by Streamable HTTP in a later spec revision; treat it as legacy and do not build new servers on it.
Local, single user, zero infrastructure
The host spawns your process and pipes JSON-RPC through it. No port, no TLS, no OAuth, no deployment. The server inherits the user's identity and privileges, so authorization is simply "the user already had this access."
Use it for: filesystem access, git, local databases, shell utilities, anything that must run where the user's data lives.
Remote, multi-user, real infrastructure
One endpoint, typically /mcp, handling POST for client messages and optionally GET for a server-initiated stream. Scales horizontally if you run it stateless; session-bound mode needs sticky routing.
Use it for: SaaS integrations, anything shared across users, anything you want to deploy once and update centrally.
The decision is about where the data lives and who else needs the server, not performance. "On this laptop, just me" means stdio, and saves you an entire authorization subsystem. "In our cloud, everyone" means HTTP.
Building an MCP Server From Scratch
The Python SDK's FastMCP class and the TypeScript SDK's McpServer class both generate the JSON Schema, the handshake, and the JSON-RPC plumbing from ordinary typed functions. A working server with one tool and one resource is about twenty lines in either language.
Python
# uv is the fastest path, but pip works identically
uv init weather && cd weather
uv add "mcp[cli]" httpx
# or, with pip
pip install "mcp[cli]" httpx
from mcp.server.fastmcp import FastMCP
import httpx
mcp = FastMCP("weather")
# The docstring becomes the tool description the model reads.
# The type hints become the input JSON Schema.
@mcp.tool()
async def get_forecast(latitude: float, longitude: float) -> str:
"""Get the weather forecast for a point on Earth.
Call this whenever the user asks about weather, rain, temperature,
or conditions at a named place.
Args:
latitude: Latitude in decimal degrees, -90 to 90.
longitude: Longitude in decimal degrees, -180 to 180.
"""
url = f"https://api.weather.example/v1/point/{latitude},{longitude}"
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(url)
r.raise_for_status()
data = r.json()
return f"{data['summary']}, {data['temp_f']}F, wind {data['wind_mph']} mph"
# A resource: read-only, addressed by URI, no side effects.
@mcp.resource("config://units")
def units() -> str:
"""The unit system this server reports in."""
return "imperial"
if __name__ == "__main__":
mcp.run() # stdio by default; mcp.run(transport="streamable-http") for HTTP
Two details carry more weight than they look like they should. The docstring is the tool description, which makes it prompt engineering rather than documentation. And the return value enters the model's context verbatim, so return a compact readable string, not a raw API dump. Fifty kilobytes of JSON for a two-line answer is the fastest way to blow a context budget.
TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "weather", version: "1.0.0" });
server.registerTool(
"get_forecast",
{
title: "Get forecast",
description:
"Get the weather forecast for a point on Earth. Call this whenever " +
"the user asks about weather, rain, temperature, or conditions.",
inputSchema: {
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
},
},
async ({ latitude, longitude }) => {
const res = await fetch(
`https://api.weather.example/v1/point/${latitude},${longitude}`
);
if (!res.ok) {
// Report tool failure in-band so the model can recover.
return { isError: true, content: [{ type: "text", text: `Upstream error ${res.status}` }] };
}
const d = await res.json();
return { content: [{ type: "text", text: `${d.summary}, ${d.temp_f}F` }] };
}
);
await server.connect(new StdioServerTransport());
Note the error handling. A failing tool should return isError: true with an explanatory message rather than throwing: a returned error reaches the model, which can retry differently or tell the user, while a thrown exception becomes a protocol-level error the model never sees. The TypeScript guide covers the type system doing the schema work here.
Connecting Your Server to a Client
Three clients cover most real usage: a desktop assistant configured through a JSON file, a CLI agent configured through a command, and your own application calling the Claude API's MCP connector. All three consume the same server.
Claude Desktop
Edit ~/Library/Application Support/Claude/claude_desktop_config.json on macOS, or %APPDATA%\Claude\claude_desktop_config.json on Windows. The file may not exist yet; create it. It is strict JSON, so no comments and no trailing commas, and command must be an absolute path: the app launches servers with a minimal environment and does not inherit your shell PATH.
{
"mcpServers": {
"weather": {
"command": "/Users/you/.local/bin/uv",
"args": ["--directory", "/Users/you/code/weather", "run", "weather.py"],
"env": { "WEATHER_API_KEY": "sk-..." }
}
}
}
Restart the app fully after editing. The Claude Desktop guide covers the rest of the surface.
Claude Code
# local stdio server
claude mcp add weather -- uv --directory /Users/you/code/weather run weather.py
# remote HTTP server
claude mcp add --transport http acme https://mcp.acme.example/mcp
claude mcp list
Scope matters here: a server added to a project config travels with the repository, while a user-scoped server follows you across projects. See the Claude Code guide for the wider workflow.
Your own application, via the Claude API
If you are building the host yourself, you can either implement a client with the SDK or let the API connect to a remote server for you. The connector requires two parameters together: the server list, and a toolset entry that references it by name.
import anthropic
client = anthropic.Anthropic()
response = client.beta.messages.create(
model="claude-opus-5",
max_tokens=4096,
betas=["mcp-client-2025-11-20"],
mcp_servers=[
{"type": "url", "name": "weather", "url": "https://mcp.acme.example/mcp"}
],
# Every server must be referenced by exactly one toolset, or the
# request is rejected as a validation error.
tools=[{"type": "mcp_toolset", "mcp_server_name": "weather"}],
messages=[{"role": "user", "content": "What is the forecast for Ames, Iowa?"}],
)
Only remote servers work this way, since the API cannot spawn a process on your machine. For local servers you implement the client yourself with the MCP SDK and pass tool results back through normal tool use. The Claude API guide covers that loop.
Debugging: The Failures Everyone Hits First
The single most useful tool is the MCP Inspector, a local web UI that connects to your server directly and lists its tools, calls them with arguments you type, and shows raw JSON-RPC traffic. It needs no host application, so it isolates whether a bug is in your server or in the wiring.
npx @modelcontextprotocol/inspector uv --directory /Users/you/code/weather run weather.py
# TypeScript build
npx @modelcontextprotocol/inspector node build/index.js
Never write to stdout in a stdio server
On the stdio transport, standard output is the protocol channel. A single stray print() or console.log() injects non-JSON into the stream and the client drops the connection, usually with an unhelpful parse error. Log to stderr instead: logging.basicConfig(stream=sys.stderr) in Python, console.error() in Node. This one rule accounts for a large share of "my server connects and immediately dies" reports.
The other four usual suspects
- Command not found. Desktop apps launch servers with a minimal environment and no shell profile, so
uv,node, andnpxare often not on PATH. Runwhich uvand paste the absolute path in. - Relative paths resolve somewhere unexpected. The working directory is the host's, not your project's. Use absolute paths.
- Missing environment variables. Your
.envis not loaded automatically. Pass secrets through theenvblock. - Server starts but no tools appear. Check
~/Library/Logs/Claude/mcp.logandmcp-server-<name>.logon macOS. An unserializable schema or an exception during registration shows up there.
Security and Authorization
The spec defines authorization only for HTTP transports, built on OAuth 2.1. An MCP server is an OAuth Resource Server: it publishes protected resource metadata so a client can discover the right authorization server, and it must reject any token that was not issued for it specifically. stdio servers are out of scope because they already run as the user, with the user's privileges, on the user's machine.
Three requirements do the heavy lifting. Clients must use PKCE on the authorization code flow, and must send a resource indicator naming the server they intend to call so the token's audience gets bound. Servers must validate that audience and refuse anything else. Together these close token replay: a token minted for server A is useless at server B, even when both trust the same identity provider.
Token passthrough is explicitly forbidden
It is tempting to accept whatever bearer token the client sends and forward it to the upstream API you are wrapping. Do not. It destroys the audience binding, hides the real caller from the upstream service's logs and rate limits, and turns your server into a confused deputy spending someone else's credentials. Issue your own token for your own resource, and hold the upstream credential server-side.
A second risk has nothing to do with tokens. Anything a server returns becomes model context, including tool descriptions, resource contents, and error strings. If your server reads issues, emails, or web pages, anyone who can write to those surfaces can write instructions into the model's context. That is why write-capable tools belong behind explicit human approval rather than blanket auto-approval. Our prompt injection guide covers the attack patterns; if the OAuth vocabulary above is unfamiliar, OAuth explained simply and the JWT guide are the prerequisites.
Be equally deliberate about which servers you install. A local stdio server has your filesystem, your SSH keys, and your shell. Read the code, or trust the publisher the way you would trust a shell script you were about to pipe into bash.
Production Concerns
The quickstart works on the first try. What breaks later is context budget, connection lifecycle, and version drift.
Tool count is a context tax. Every connected server's schemas are serialized into the request on every turn. Five servers with fifteen tools each means seventy-five schemas in front of every message, which costs tokens and degrades selection accuracy. Ship the smallest useful surface: one tool with a well-typed parameter beats four near-duplicates. Names collide too, since two servers can both define search, so namespace by domain (crm_search_accounts).
Design HTTP servers stateless when you can. Any instance can then answer any request, which means ordinary load balancing. Session-bound mode needs sticky routing or a shared store, and only pays off if you truly need server-initiated notifications. Give every tool a timeout, and make write tools idempotent so a retry after a timeout does not double-charge anyone. Reply with your highest supported protocol version rather than assuming a match, pin SDK versions, and read the changelog before upgrading.
Instrument the boundary. Log every tool call with its name, argument shape, duration, and outcome, to stderr for stdio and to your normal pipeline for HTTP. When an agent misbehaves, the answer is nearly always visible in the tool call sequence. Standard API design practice transfers to MCP servers unchanged: version deliberately, fail loudly, never surprise the caller.
MCP vs the Alternatives, and When to Skip It
MCP replaces neither function calling nor agent frameworks. It sits underneath both, standardizing how a tool implementation reaches an application. The honest comparison is against direct function calling, an OpenAPI spec plus a generated wrapper, and a framework's built-in tool abstraction.
| Dimension | MCP | Direct function calling | OpenAPI wrapper | Framework tools |
|---|---|---|---|---|
| Reuse across apps | Write once, any client | None, per-app code | Good, per-language codegen | Only within that framework |
| Runtime discovery | Yes, with change notifications | No, compiled in | Partial, spec is static | Usually no |
| Server can call back to the model | Yes, sampling and elicitation | No | No | No |
| Setup cost | SDK plus a process or endpoint | Lowest, a function | Moderate, spec plus codegen | Low inside the framework |
| Latency overhead | One process or network hop | None | Network hop | None |
| Auth story | OAuth 2.1 defined by spec | Yours entirely | Whatever the API uses | Yours entirely |
| Best for | Integrations used by many apps or many teams | One app, one integration you own | Existing REST APIs with a stable contract | Prototyping inside one codebase |
The frameworks are complements, not rivals. LangChain and LangGraph both consume MCP servers as tool sources: the framework owns orchestration, MCP owns the integration boundary.
When a plain API call is the better choice
- One app, one integration, no reuse planned. A function in your codebase is simpler, faster, and easier to test.
- The model does not choose the tool. If your pipeline always calls the same three endpoints in order, that is a workflow, not agency. Call them directly.
- Latency is the product. A process spawn or a network hop plus a handshake is real time. On a voice assistant's hot path, in-process wins.
- You only need retrieval. An index and a search call pull documents into context with less machinery than a tool-calling loop.
- The consumer is your own code, not an assistant. MCP exists to describe capabilities to a model. Service-to-service traffic already has REST and gRPC.
The bottom line: build a server when the integration will be consumed by more than one application, by more than one team, or by a client you do not control. Write a function when it will not.
Frequently Asked Questions
What is the Model Context Protocol in simple terms?
MCP is an open standard that lets any AI application talk to any tool or data source through one protocol instead of a custom integration per pair. A server publishes capabilities: tools the model can call, resources it can read, and prompt templates a user can invoke. A client inside a host app connects to that server over JSON-RPC 2.0, using stdio or HTTP. Because the interface is standardized, a server written once works in every MCP-capable client.
Do I need MCP if my model already supports function calling?
Not necessarily. Function calling is the model-side mechanism for choosing a tool and emitting arguments; MCP is the transport layer that moves tool definitions and results between processes. One application calling functions you control is simpler with plain function calling. MCP earns its keep when the same integration is reused across applications, when the tool provider and the app author are different teams, or when tools must be discoverable at runtime rather than compiled in.
Should I use stdio or Streamable HTTP for my MCP server?
Use stdio when the server runs on the user's machine and needs local files, git, databases, or shell access. The host launches it as a subprocess and speaks JSON-RPC over standard input and output, so there is no port, no TLS, and no authorization layer to build. Use Streamable HTTP when the server is remote, shared across users, or load-balanced. HTTP buys reachability and multi-user access, and costs you OAuth, rate limiting, and per-user isolation.
Is MCP safe to use with sensitive data?
It can be, but the protocol does not make it safe by itself. A local stdio server runs with your full user privileges and no sandbox. The spec forbids token passthrough, so a server must reject any access token not issued for it, and it requires OAuth 2.1 with PKCE plus resource indicators so a token minted for one server cannot be replayed at another. Treat every tool description and returned document as untrusted input, and keep human approval on any tool that writes, sends, deletes, or spends.
References: Model Context Protocol documentation, the MCP specification, the modelcontextprotocol GitHub organization (SDKs, reference servers, and the Inspector).