Field Guide

Build an MCP server.
Start with the boring truth.

An AI can't do anything. It can only talk. Every time one seems to look something up, or send something, or change something, an ordinary program did that part. MCP is how you hand it those programs. Three questions below: what it is, how it works, how you build one.

Protocol revision 2026-07-28
Depth
01

What MCP actually is

Three questions. No jargon. If you read nothing else here, read this.

What is it?

A plug shape.

You write a small program that says "I can look up the weather." Any AI app can plug into it and use it. You write it once. You don't write a version for Claude and another for your editor and another for the thing your company built.

That's MCP. It's the agreed shape of the plug.

Without it: ten apps that each want fifty data sources means five hundred piles of one-off code. With it: ten plus fifty, and they all fit together. Your program works in apps that didn't exist when you wrote it.

What it won't do

It won't make the AI smarter. It won't let it do anything it couldn't already do if you wrote the code yourself. It's a plug. Worth a lot once everyone agrees on one, worth almost nothing on its own.

How does it work?

Think of a phone call. You ring a friend who's a brilliant cook. They're not in your kitchen. They can't open your fridge. They can't see what's in it.

So you tell them what you've got: a fridge, an oven, a scale.
They say, "Open the fridge. What's in there?"
You open it. They don't. You read out what you find.
They say, "Make an omelette."

Your friend is the AI. You are the app. The fridge is your program. Your friend never touched a thing in your house — they only ever said words. Everything that actually happened, you did.

So when someone says the AI "called a tool", here's what really happened. The AI wrote a bit of text saying please run get_forecast for Denver, and stopped. A normal program read that text, made the call, typed the answer back into the conversation, and started the AI up again. The AI has no internet. It can't run anything.

Which is why a tool with a woolly name gets ignored. The AI picks by reading names and descriptions and nothing else.

The same thing, in the words the docs use

Reading out your kitchen is tools/list. "Open the fridge" is a tool_use block. You opening it is the app making a tools/call. What you read back is a tool_result. "Make an omelette" is the final message. Only the two tools/ steps are MCP; the rest is the app talking to the model.

How do I build one?

Write a function. Tag it. Start it. That's a working server:

from mcp.server import MCPServer

mcp = MCPServer("weather")

@mcp.tool()
def get_forecast(city: str) -> str:
    """Get tomorrow's forecast for a city."""
    return f"{city}: 54F, partly cloudy."

mcp.run(transport="stdio")

Point an app at that file and the AI can use it. Section 08 does it properly, in Python and TypeScript, with the install and the config.

The docstring is not decoration. That sentence is the entire basis on which the AI decides whether to call your function. Write it for a stranger who has to choose between yours and four others.

Where things live

stdio means the app starts your file as a program on the user's own machine and talks to it through its input and output. Their files never leave the room. If the server needs to run somewhere else instead, you swap one line for Streamable HTTP and nothing else changes.

02

The three players

Host, client, server. Notice who isn't on the list: the AI. It's on the end of the phone, outside all three. Click a node.

MCP HOST AI application Client 1 dedicated connection Client 2 dedicated connection Client 3 dedicated connection Filesystem stdio · local Database stdio · local Sentry HTTP · remote

The relationship that trips people up

It is one client per server, not one client per host. Connecting to three servers means the host instantiates three clients, each holding its own connection.

"Local" and "remote" describe where a server runs, not what it is. A stdio server is launched by the host as a subprocess and typically serves exactly one client. An HTTP server runs wherever you deploy it and serves many clients at once — which means it needs to think about authorisation and concurrency in a way a stdio server never does.

Implementation note

Two clients in the same host can hold two connections to the same remote server. If your server keeps any per-connection state, that assumption breaks the moment a user opens a second window. As of revision 2026-07-28 the protocol is explicitly stateless, so lean on that: derive everything from the request.

03

Two layers

MCP comes in two halves. One of them you'll stop thinking about after today. The other one you have to pick.

Data layer

This is the half that's the same everywhere. Messages are JSON-RPC 2.0: you send a method name and an id, and the reply comes back carrying the same id so you know what it answers. Whether those bytes went down a pipe on your laptop or across the Atlantic makes no difference to a single character of it.

There are four families of message. Discovery asks who you are and what you can do. Server features are the things you offer — tools, resources, prompts. Client features are the things you can ask the app for. Utilities are the housekeeping: notifications, progress, paging.

A mistake worth knowing about now

Replies are matched to requests by id. If a message arrives with a method but no id, it's a notification — do not answer it. Answering is a protocol violation, and some clients will silently bin your message rather than complain, which makes it a horrible thing to track down later.

Transport layer

This is the half you choose: how those messages actually get from one place to the other. If your server runs on the same machine as the app, it's a pipe. If it runs somewhere else, it's HTTP. That's the whole decision, and where the server lives usually makes it for you.

Opening the connection, marking where one message ends and the next begins, and proving who you are all belong down here. They're kept apart from everything else on purpose, so the same server code runs over either one — usually you change a single line.

Why a local server has no password

Proving who you are belongs to the transport, and a pipe has no way to do it — nor does it need one. The app started your server itself, on the user's own machine, so it already has exactly the trust that app had. Over HTTP you're back to the usual kit: bearer tokens, API keys, headers. The spec suggests OAuth for handing them out.

Stateless as of 2026-07-28

Your server doesn't remember anything between one request and the next, and it doesn't need to. Everything required to answer a message arrives with that message. No sessions, no setup call, nothing to keep in memory.

Every request brings a _meta block with the protocol version, what the client can do, and usually who it is. When a client does want to know what you offer, it asks server/discover — though nothing forces it to ask before anything else.

If you learned MCP before

This replaces the initialize / notifications/initialized handshake you may be carrying in your head. There is no session to establish. Responses now also carry caching hints — resultType, ttlMs, and cacheScope — so a client can reuse a tool list for the window you nominate instead of re-listing constantly.

04

The three server primitives

Three kinds of thing your server can offer. They look alike on paper and people mix them up constantly. What actually separates them is not what they do — it's who gets to decide when they're used.

PrimitiveControlled byMethodsExample
ToolsThe model tools/list
tools/call
Send a message, query a database
ResourcesThe application resources/list
resources/templates/list
resources/read
A file's contents, a schema
PromptsThe user prompts/list
prompts/get
A slash-command workflow
Model-controlled

Tools — things the model can do

A tool is a function with typed inputs, and the model decides when to call it. It reads your names and descriptions, picks one, fills in the arguments, and asks for it. Most servers are mostly tools.

Each tool declares a name, a description, and an inputSchema in JSON Schema. The result is a content array, so a tool can return text, images, or embedded resources rather than just a string.

Because the model chooses, hosts wrap tools in human oversight: approval dialogs, per-tool permissions, activity logs. Design for that — your title and description may end up in a confirmation prompt a user has half a second for.

The description is the interface

Treat it as the thing that decides whether your tool ever gets used, because it is. Say when to reach for the tool, not only what it does. Namespace names (calculator_arithmetic, not calculate) — a host federating six servers will otherwise hand the model three tools called search. And keep the surface small: an overstuffed tool list degrades selection accuracy well before it hits any context limit.

Application-controlled

Resources — things the model can read

A resource is data the app can read — a file, a schema, a page. Each one has an address, the way a web page does: file:///notes.md, calendar://events/2024. Here's the difference that matters: the model doesn't choose these. Your app decides what to fetch and how much of it to use.

Two discovery shapes. Direct resources are fixed URIs. Resource templates are parameterised — weather://forecast/{city}/{date} — and support parameter completion, so a UI can suggest "Paris" as the user types "Par".

Why the split matters for safety

Think about what each one is allowed to do. A tool acts, so it gets gated; a resource is inert data the app chose to read, so it usually isn't. Don't smuggle side effects into a resources/read handler — you're bypassing the consent path the host built. To watch resources for changes, the client subscribes with the URIs in a resourceSubscriptions filter and receives notifications/resources/updated.

User-controlled

Prompts — workflows a person invokes

A prompt is a ready-made piece of work that a person chooses on purpose — usually it turns up as a slash command. Nothing sets one off by itself. The model can't reach for one and neither can the app; someone has to pick it.

A prompt declares typed arguments, and can reference your tools and resources to compose a whole workflow. It's the natural place to encode "here is the good way to use this server" rather than hoping the model infers it.

The one everybody skips

Prompts get ignored more than anything else here, and they're often where the most value is. If your server needs three tools called in a particular order to be useful, a prompt encodes that sequence once instead of relying on every model to rediscover it. Note that host support varies — build so the server is still usable if prompts are never surfaced.

05

Client primitives

Up to now the server has only answered questions. These are the things it can ask for. One of them was recently taken out, so if a tutorial tells you otherwise, check its date.

Elicitation — asking the user something

A server can request input mid-operation via elicitation/create — to confirm a destructive action, or ask for a value it can't infer.

Delivered through the Multi Round-Trip Requests pattern, since it needs a reply partway through handling the original request. The client declares "elicitation": {} in its capabilities; if it doesn't, don't call it.

Implementation note

Always design a non-interactive fallback. Plenty of hosts run unattended — a batch job, a scheduled agent — and a server that blocks on elicitation simply hangs there. Check the declared capability and take a sensible default when it's absent.

Deprecated in 2026-07-28

Sampling and logging

Sampling (sampling/createMessage) let a server borrow the host's model. Logging sent log messages to the client. Both are deprecated as of this revision.

New servers should call an LLM provider's API directly instead of sampling, and log to stderr (stdio) or via OpenTelemetry instead of the logging primitive.

Implementation note

If you're following an older tutorial, this is the most likely place it's out of date — sampling was widely taught as a core client primitive. Deprecated is not removed, so existing implementations keep working, but don't build new work on it.

06

On the wire

Here is a whole conversation, one message at a time, with the real payloads. Click through it. Once you've clicked the panel, the arrow keys work too.

Implementation note

Request

Response

07

Choosing a transport

There are two, and you barely get a say — where your server runs decides it for you.

stdio

The app starts your server as a program on the user's own machine and talks to it through its input and output — the same way two commands joined by a | talk to each other. Nothing crosses a network. There's no port to open and no password to check.

Best for anything touching the local machine — filesystem, git, local databases. Usually one client per server. Start here: it's the shortest path from nothing to a working server.

The one that catches everyone

Your stdout is the wire. A stray print() or console.log() injects garbage into the JSON-RPC stream and the connection dies with an unhelpful parse error. Log to stderr — that's why the TypeScript example below uses console.error for its startup banner.

Streamable HTTP

Your server runs somewhere else and the app reaches it over HTTP. Requests go up as ordinary POSTs. If you want to send results back a piece at a time as they're ready, you can. One server can serve a lot of people at once.

The choice for anything hosted. Brings standard HTTP auth — bearer tokens, API keys, custom headers — and the spec recommends OAuth for obtaining them.

What you've signed up for

You are now writing a multi-tenant service, with everything that implies: per-request authorisation, rate limiting, and no cross-request state. The protocol's statelessness helps — but it's on you to make sure one user's token can't read another's resources.

08

Build one

A weather server with two tools, over stdio. Roughly forty lines in either language.

Step 1 — Install
uv init weather && cd weather
uv add "mcp[cli]"
Step 2 — Create the server
from mcp.server import MCPServer

mcp = MCPServer("weather")

NWS_API_BASE = "https://api.weather.gov"
Step 3 — Register a tool

The decorator does the schema work. Type hints become the inputSchema; the docstring becomes the description the model reads.

@mcp.tool()
async def get_alerts(state: str) -> str:
    """Get weather alerts for a US state.

    Args:
        state: Two-letter US state code (e.g. CA, NY)
    """
    url = f"{NWS_API_BASE}/alerts/active/area/{state}"
    data = await make_nws_request(url)

    if not data or "features" not in data:
        return "Unable to fetch alerts or no alerts found."
    if not data["features"]:
        return "No active alerts for this state."

    return "\n---\n".join(format_alert(f) for f in data["features"])
Implementation note

That docstring is load-bearing — it's what the model uses to decide whether this tool answers the question in front of it. Write it as a routing instruction, not as a comment for the next developer.

Step 4 — Run it
if __name__ == "__main__":
    mcp.run(transport="stdio")
Test before you wire it to anything. The MCP Inspector connects to your server and lets you list and call tools by hand — far faster than debugging through a host, where a failure looks like the model just declining to use your tool.
09

Use it from your own app

Everything up to here was the server. This is the other side: the thirty lines in your own program that actually make use of it. Almost nobody shows you this part, and it's the part that makes the rest make sense.

The whole loop

Your app does five things, in a loop, until the model stops asking for things. Ask the server what it has. Hand that list to the model. Wait for the model to name one. Go and call it. Give the answer back.

from anthropic import Anthropic

client = Anthropic()
messages = [{"role": "user", "content": "What's the weather in Denver tomorrow?"}]

# 1. ask the server what it has                        ← MCP
tools = await session.list_tools()

# 2. rename one field. that is the whole translation.
api_tools = [{
    "name":         t.name,
    "description":  t.description,
    "input_schema": t.inputSchema,
} for t in tools.tools]

while True:
    # 3. hand the list to the model                    ← not MCP
    msg = client.messages.create(
        model="claude-opus-5",
        max_tokens=1024,
        messages=messages,
        tools=api_tools,
    )
    messages.append({"role": "assistant", "content": msg.content})

    if msg.stop_reason != "tool_use":
        print(msg.content[0].text)      # it is done. this is the answer.
        break

    # 4. the model named one. you go and call it.      ← MCP
    results = []
    for block in [b for b in msg.content if b.type == "tool_use"]:
        out = await session.call_tool(block.name, block.input)
        results.append({
            "type":        "tool_result",
            "tool_use_id": block.id,
            "content":     out.content[0].text,
        })

    # 5. give the answers back and go round again      ← not MCP
    messages.append({"role": "user", "content": results})

Two lines there are MCP. Everything else is an ordinary API call and a while loop you wrote yourself.

Notice what the model never does. It doesn't open a connection. It doesn't call your function. It hands back a block that says this name, these arguments, and then it waits. You do the calling. That's why the tool result goes back in as a "role": "user" message — from the model's point of view, somebody outside went and looked something up and is now telling it what they found.

Things that will bite you

A model can ask for several tools in one turn, so loop over the blocks rather than grabbing the first — that's why the example collects a list. Send every result back in a single message. And put a ceiling on the loop; a model that keeps asking for the same failing tool will happily spin until your bill notices.

The translation is one renamed field

People expect this bit to be the hard part. It isn't. MCP describes a tool as name, description, inputSchema. The Messages API wants name, description, input_schema. Same three things. One of them is spelled differently.

That's not a coincidence — both sides are describing a function with JSON Schema, because there isn't a second sensible way to do it. If you're wiring MCP to a different model provider, the shape you're converting into will look much the same.

If you're connecting several servers

You'll be merging tool lists from more than one server into one flat array for the model, and then you need to know which server a name came from when the model picks it. Keep a dictionary from tool name back to session, and prefix names per server so two servers can't both hand you a search.

You are allowed to skip MCP entirely

Write that api_tools list by hand, call your own functions in step 4, and steps 3 and 5 don't change by a character. The loop works exactly the same and you never touch MCP. For a lot of apps that is the right answer, and nobody should talk you out of it.

MCP starts earning its place at exactly two moments: when the tool list comes from a program you didn't write, and when you want the thing you wrote to be usable by an app you didn't write.

Which is the honest test for whether you need this at all. One app, your own tools, no plans to share them? Hardcode the list and get on with your life. Building something other people will plug into, or plugging into something somebody else built? Then you want the standard plug, and this is it.

10

Build one out of blocks

It starts already built and already working — press Run a session and watch a request go all the way through. Then take it apart. Drag pieces onto the board and drop them inside each other; where a thing sits is what makes it true. To join two pieces, press connect on one and click the other — the board will tell you what that wire means, or why it is not a wire at all. Everything you connect is listed on the right, with a × beside it. Two of them are real: the app calls a model to decide what to do, and a client calls a server to do it. Only the second one is MCP.

Start from

drag a piece to move it · drag a box's corner to resize it · drag empty space to pan

Next

Enabled once the build is complete.
    The model is not inside MCP. Run a session and watch the order: the client collects the tool list over MCP, the application hands that list to the model in an ordinary API call, the model answers with a tool_use block, and only then does the client make the tools/call. MCP is how the application finds and reaches tools. Deciding which one to use is the model's job, and it does it entirely from names and descriptions. Drag the model onto the same machine as the app and the wire relabels itself — a local model changes nothing about the protocol.
    Nothing here declares a transport. Put the server on the same machine as the host and the connection is stdio — the host can launch it as a subprocess and talk over its stdin and stdout. Drag that same server into the cloud and the label changes to Streamable HTTP by itself, because the messages now have to cross a network. That is the whole of the transport decision, and it is made by where things live.
    11

    What actually goes wrong

    The failures that cost an afternoon, in rough order of how often they happen.

    Writing to stdout

    One print() in a stdio server and the connection dies with a parse error that names none of this.

    Route all logging to stderr. If a dependency prints to stdout, capture and redirect it at startup before you connect the transport.

    Descriptions written for humans

    "Gets weather data" tells the model nothing about when to reach for it. The symptom is a tool that never gets called.

    Say what it does and when to use it. If a tool is being ignored, rewrite the description before you touch anything else — it's the single highest-leverage change.

    Too many tools

    Selection accuracy degrades with surface area, and a host may be federating several servers alongside yours.

    Prefer a few well-bounded tools over many granular ones. Where two tools overlap, say so explicitly in both descriptions so the model can tell them apart.

    Treating notifications as reliable

    Delivery is best-effort and nothing is replayed across a reconnect.

    They're a latency optimisation over polling, not a source of truth. Keep a refresh interval underneath them or your client's view goes quietly stale.

    Following a tutorial written for an older revision

    The initialize handshake, sampling as a core primitive, always-on notifications — all superseded.

    Check the revision date on anything you're reading. This page tracks 2026-07-28; the spec moves, and the SDK package names have moved with it.