Model Context Protocol

A visual developer's guide to MCP.

Everything you need to build, debug, and reason about MCP — from the JSON-RPC envelope to the host-side orchestration loop.

Written by Blijnder — makers of Stedt, the platform powering strong regional communities.

3Primitives
tools · resources · prompts
5Session stages
connect → result
2Transports
stdio · streamable HTTP
1Open spec
JSON-RPC 2.0
How to read this page

Dotted words like JSON-RPC open a definition popover when clicked. Orange-highlighted segments inside code blocks are annotated — click them to learn what that field does. Diagrams are clickable. Use the table of contents on the left to jump around.

01 · The big picture

One host, many servers, one model.

MCP defines how an AI app (the host) talks to external capabilities. The host runs locally on your machine; it spawns one MCP client per MCP server you've configured. Each server is its own process (or remote URL) exposing tools, resources, and prompts. The model — the thing actually generating text — runs server-side at Anthropic and never speaks to your MCP servers directly. The host is the broker.

Click any element below to see what it does.

MCP architecture: host with two clients connected to two servers A host application contains the model bridge and two MCP clients. Each client connects via JSON-RPC over stdio or HTTP to an external MCP server, each of which exposes tools, resources, and prompts. Host application Claude Desktop · Cowork · IDE Model bridge Talks HTTPS to Anthropic API MCP client A JSON-RPC pipe to Stedt MCP client B JSON-RPC pipe to Moneybird claude_desktop_config.json { "mcpServers": { … } } MCP server Stedt tools resources prompts Local Python process · stdio MCP server Moneybird tools resources prompts Remote HTTPS · Streamable HTTP JSON-RPC 2.0 JSON-RPC 2.0

Click any box in the diagram above to see what it is and what it does.

Three takeaways from this picture

One client per server. The host doesn't multiplex servers over a shared connection. If Stedt and Moneybird are both configured, the host spawns two clients, each holding its own pipe. This keeps server crashes isolated and lets the host implement per-server permissions, timeouts, and logging.

Servers are dumb processes, by design. A server exposes capabilities. It doesn't know which model is running, what other servers exist, or what the user asked. It receives a tools/call, runs the function, returns content. That's it. The intelligence sits in the host's orchestration loop and the model.

The model is remote. Even when every server is local, the actual inference call is an HTTPS request to api.anthropic.com. Latency-wise, a single tool call typically involves: one round trip to the API to get a tool_use, one local IPC to the server, and one more API round trip to feed the result back.

02 · Session lifecycle

Every MCP session moves through five stages.

From the moment the host decides to use a server until the moment the user sees an answer, MCP follows a predictable script. Click any stage to expand it.

1
Connect
Spawn the server process or open the HTTP stream

For stdio servers, the host reads its config file, finds the command and args, and uses the OS to spawn that as a child process. It connects its own pipe to the child's stdin and stdout. Nothing has been sent yet — the wire is just open.

For HTTP servers, the host opens a POST connection to the server's URL and keeps it warm. The server may also open a long-lived SSE stream the other direction for server-initiated messages (sampling, notifications).

{
  "mcpServers": {
    "stedt": {
      "command": "uvx",
      "args": ["stedt-mcp"],
      "env": { "STEDT_API_KEY": "sk-..." }
    },
    "moneybird-remote": {
      "url": "https://mcp.moneybird.com/v1",
      "headers": { "Authorization": "Bearer ..." }
    }
  }
}
2
Initialize
Negotiate protocol version and capabilities

The client sends an initialize request stating which spec version it speaks and what it supports (sampling, roots, etc.). The server replies with its own version and a list of which primitives it exposes. Both sides then know exactly what's on the table for this session.

After receiving the response, the client sends a one-way notification called notifications/initialized. No reply. From this point on, normal traffic can flow.

Client → Serverinitialize request
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-03-26",
    "capabilities": {
      "sampling": {},
      "roots": { "listChanged": true }
    },
    "clientInfo": { "name": "claude-desktop", "version": "0.9.2" }
  }
}
Server → Clientinitialize response
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-03-26",
    "capabilities": {
      "tools":     { "listChanged": true },
      "resources": { "subscribe": true, "listChanged": true },
      "prompts":   { "listChanged": false },
      "logging":   {}
    },
    "serverInfo": { "name": "stedt", "version": "2.4.1" }
  }
}
3
Discover
tools/list · resources/list · prompts/list

Now the host asks the server what's actually available. Three list methods: tools/list, resources/list, prompts/list. Each returns an array. Crucially, every tool ships with a JSON Schema describing its arguments — the host hands that schema (and the description) straight to the model as the tools[] parameter in the next API call.

Server → Clienttools/list response
{
  "jsonrpc": "2.0", "id": 2,
  "result": {
    "tools": [
      {
        "name": "community_search",
        "description": "Full-text search across Stedt regional communities. Returns community IDs and one-line summaries — use community_retrieve for full member rosters and event feeds.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "query":  { "type": "string", "description": "Free-text query" },
            "limit":  { "type": "integer", "default": 10, "maximum": 50 },
            "region": { "type": "string", "enum": ["north", "central", "south"] }
          },
          "required": ["query"]
        }
      }
    ],
    "nextCursor": null
  }
}

List responses are paginated via a cursor field. If nextCursor is non-null, the host calls tools/list again with params.cursor set to that value.

4
Invoke
tools/call with name and arguments

The model has produced a tool_use. The host validates the arguments against the schema it cached at discovery time, then sends a tools/call request over the open pipe. The id on this JSON-RPC envelope is independent of the model's tool_use.id — the host keeps a map between them.

Client → Servertools/call request
{
  "jsonrpc": "2.0",
  "id": 17,
  "method": "tools/call",
  "params": {
    "name": "community_search",
    "arguments": { "query": "NHG", "limit": 5 }
  }
}

If the server supports progress reporting and the request is expected to be slow, the client can include a _meta.progressToken in params; the server then emits notifications/progress events as it works.

5
Result
Content blocks flow back into the LLM context

The server replies with an array of content blocks. Each block has a typetext, image, audio, or resource — plus the appropriate payload. If something went wrong, isError is true and the text content explains what.

Server → Clienttools/call response
{
  "jsonrpc": "2.0", "id": 17,
  "result": {
    "content": [
      { "type": "text",  "text": "Found 3 communities matching NHG:\n1. ..." },
      { "type": "image", "data": "iVBORw0KGgo...", "mimeType": "image/png" }
    ],
    "isError": false
  }
}

The host wraps these blocks in a tool_result and ships them back to the API in the next request. The model now sees them as part of its conversation history.

03 · Wire protocol

Every message is a JSON-RPC 2.0 envelope.

If you've used JSON-RPC before, MCP holds no surprises. There are three message shapes: request (has an id, expects a response), response (carries the matching id plus a result or error), and notification (no id, no reply). Servers can send any of these to clients and vice versa.

The orange highlights below are clickable — they explain each field.

A request

Client → ServerAnnotated request
{
  "jsonrpc": "2.0",
  "id": 17,
  "method": "tools/call",
  "params": {
    "name": "community_search",
    "arguments": { "query": "NHG", "limit": 5 }
  }
}

A successful response

Server → ClientAnnotated success
{
  "jsonrpc": "2.0",
  "id": 17,
  "result": {
    "content": [
      { "type": "text",
        "text": "Found 3 communities matching NHG..." }
    ],
    "isError": false
  }
}

An error response

Errors use the JSON-RPC error object with a numeric code and human-readable message. The MCP spec adds a handful of conventions on top of the standard JSON-RPC error codes.

Server → ClientAnnotated error
{
  "jsonrpc": "2.0",
  "id": 17,
  "error": {
    "code": -32602,
    "message": "Invalid params: 'query' is required",
    "data": { "field": "query" }
  }
}

A notification

Notifications carry no id. The other side processes them and sends no reply. Used for events: tool list changed, resource list changed, progress updates, log messages.

Server → ClientAnnotated notification
{
  "jsonrpc": "2.0",
  "method": "notifications/tools/list_changed"
}
A common gotcha

JSON-RPC doesn't define ordering guarantees across separate requests. If you fire tools/call #1 and tools/call #2 back-to-back, the server is free to return #2 first. Always pair responses to requests by id, never by arrival order.

04 · Three primitives

Tools, resources, prompts.

Everything an MCP server can offer falls into one of three buckets. The differences come down to who controls them and whether they have side effects.

What they are

Tools are callable functions — possibly with side effects. The model decides when to invoke them based on the conversation. Think send_email, create_invoice, search_database.

Schema shape

{
  "name": "send_email",
  "description": "Send an email to one recipient. Returns the message ID on success.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "to":      { "type": "string", "format": "email" },
      "subject": { "type": "string", "maxLength": 200 },
      "body":    { "type": "string" }
    },
    "required": ["to", "subject", "body"]
  }
}

Design tips

Tool names are visible to the model — keep them concise, lowercase, snake_case. The description is the single most important piece of prompt engineering on the server side: it's literally the prompt the model uses to decide whether to call your tool. Spell out preconditions, expected output, edge cases. Don't be cute.

Schemas should be conservative. If limit is optional with a default, say so. If category is an enum, enumerate it — the model is much more reliable when constrained.

What they are

Resources are read-only data, addressed by URI. They behave like files: the host (or the user) can attach them to the context; the model doesn't call them like tools. Examples: file://notes.md, db://users/42, github://issue/123.

Schema shape

{
  "uri": "file:///Users/jonathan/notes/2026-05-12.md",
  "name": "Today's notes",
  "description": "Running notes from today",
  "mimeType": "text/markdown"
}

The crucial distinction

Tools are model-controlled: the model decides when to invoke them. Resources are application-controlled: the host (or the user via the UI) decides when to include them. A "send_email" capability is a tool. A "the current open file in my editor" is a resource.

Servers can also support resources/subscribe so the host gets notified when a resource changes — useful for things that mutate during a session (an open IDE buffer, a database row being edited).

What they are

Prompts are user-controlled templates. They show up as commands in the host's UI (Claude Desktop renders them as slash commands). The user picks one, fills in any arguments, and the server returns a pre-built message list that gets seeded into the conversation.

Schema shape

{
  "name": "review-pr",
  "description": "Generate a careful PR review",
  "arguments": [
    { "name": "diff",  "description": "The unified diff", "required": true },
    { "name": "style", "description": "tone (strict|gentle)", "required": false }
  ]
}

When to reach for them

Use prompts for repeated, structured workflows where the user knows in advance what they want. They beat re-typing the same instructions every time. They're not a substitute for tools — the model can't call a prompt, it can only call a tool.

Who triggers it

Tools

The model picks them, based on the conversation. Have side effects. The thing you probably want most of the time.

Who triggers it

Resources

The application picks them — often via the user. Read-only. Attached to context like a file.

Who triggers it

Prompts

The user picks them explicitly from the host's UI. Templates with arguments that seed structured conversations.

05 · Transports

Two transports cover everything.

The MCP spec defines two official transports. The shape of the messages is identical in both — only the framing differs.

Transport 1

stdio

The host spawns your server as a child process and exchanges newline-delimited JSON over its stdin and stdout. stderr is reserved for logging.

Use it when

  • The server is a local tool (filesystem, git, local DB)
  • You want zero auth — the OS isolates the process
  • Distribution is via package managers (uvx, npx)

One golden rule

Don't write to stdout unless it's a JSON-RPC frame. A stray print() corrupts the stream and the client will drop the connection. Log to stderr.

Transport 2

Streamable HTTP

Single endpoint that accepts POST requests for client→server messages, and optionally upgrades to SSE for streaming responses and server→client traffic. Replaces the older HTTP+SSE design from the 2024 spec.

Use it when

  • The server is remote (SaaS, cloud)
  • Multiple clients should share one server
  • You need standard web auth (OAuth, bearer tokens)

One detail

The server identifies a session via the Mcp-Session-Id response header, which the client echoes back on subsequent requests.

Legacy: An older transport called HTTP+SSE used two endpoints (POST /messages + GET /sse). It still works against existing servers, but new code should use Streamable HTTP.
06 · Tool-call round trip

One tool call, six messages.

This is the most important diagram on the page. Step through the six messages of a single tool call. Notice that the host is in the middle of every exchange — the model never talks to the server directly.

Round-trip swim lane Model Anthropic API Host Cowork on Claude Desktop MCP server Stedt process 1. POST /v1/messages 2. tool_use block 3. tools/call (JSON-RPC) 4. result content blocks 5. POST /v1/messages + tool_result 6. final assistant text
Step 1 — Host calls the API
POST https://api.anthropic.com/v1/messages
{
  "model": "claude-sonnet-4-6",
  "system": "You are a helpful assistant…",
  "messages": [
    { "role": "user", "content": "What do we know about the NHG community?" }
  ],
  "tools": [
    {
      "name": "community_search",
      "description": "Full-text search across Stedt regional communities…",
      "input_schema": { "type": "object", "properties": { … }, "required": ["query"] }
    }
  ]
}
07 · Build your own server

A complete server in 20 lines.

Hover or click any line of the Python code below to see what it does. The right panel updates with the explanation.

1# pip install "mcp[cli]"2from mcp.server.fastmcp import FastMCP34mcp = FastMCP("weather")56@mcp.tool()7def get_weather(city: str) -> str:8    """Return the current weather for a city."""9    return f"It is 18°C and partly cloudy in {city}."1011@mcp.resource("notes://daily")12def daily_notes() -> str:13    with open("notes.txt") as f:14        return f.read()1516@mcp.prompt()17def summarize(text: str) -> str:18    return f"Summarize in 3 bullets:\n{text}"1920if __name__ == "__main__":21    mcp.run()
Click any line on the left to see what it does.

Run it

Save the file as server.py and test it locally with the MCP Inspector:

$ pip install "mcp[cli]"
$ npx @modelcontextprotocol/inspector python server.py

The Inspector is a small web UI that talks JSON-RPC to your server and lets you call tools, fetch resources, and see every byte on the wire. It's the single most valuable debugging tool while building.

Wire it into Claude Desktop

Edit ~/Library/Application Support/Claude/claude_desktop_config.json (on macOS) and add an entry:

{
  "mcpServers": {
    "weather": {
      "command": "python",
      "args": ["/absolute/path/to/server.py"]
    }
  }
}

Restart the app. Claude Desktop will spawn your process, run the handshake, and surface get_weather to the model. Ask it about the weather in Amsterdam — if everything's wired up, it'll call your tool.

08 · Pitfalls and tips

Lessons learned the hard way.

Never print() from a stdio server. Every byte you write to stdout outside a JSON-RPC frame corrupts the stream. The client will silently drop the connection and you'll have no idea why. Log to stderr (Python's logging.basicConfig(stream=sys.stderr)).
The tool description is the prompt. The model picks tools based entirely on the description field. A vague description means random tool selection. A precise one — including preconditions, return shape, edge cases — means reliable calls.
Schema tightly. If limit has a maximum, set it. If category is one of three values, make it an enum. The model is much more reliable when the schema constrains its options.
Use the _meta.progressToken for long calls. Anything taking more than a couple of seconds should emit progress notifications. Hosts use them to keep the UI alive and to let the user cancel.
Don't conflate tools and resources. If something has side effects (sends, writes, deletes), it's a tool. If it's pure read with a stable address (a file, a database row, a URL), it's a resource. Mixing them up confuses both the model and the UI.
Idempotency. The host may retry on transport errors. If your tool isn't idempotent, surface that in the description ("Always sends — do not retry on uncertain failure.") or implement an idempotency key.
Capabilities matter. If your server only exposes tools, don't advertise resources or prompts in the initialize response. Clients use that to decide whether to even call the list methods.
Pagination is real. If your server has 80 tools, return them in chunks with a nextCursor. Don't dump the whole list in one response — hosts will pass that array straight into the model's context and you'll blow through token budgets.
09 · Glossary

Vocabulary, alphabetized.

Click any card to read the full entry. Every dotted term elsewhere on this page opens the same popover.

10 · Further reading

Where to go next.