Think about LLM integrations before MCP existed. Say you built a chat app. You wanted it to read GitHub issues, query a database, and search internal docs. You had to write three separate, custom integrations. Each had its own auth. Each had its own way of describing what it does. Now say another team built a different chat app. They wrote the same three integrations again, from scratch. This is the M×N problem: M applications, each writing custom code for N data sources.
The core idea
MCP turns that into an M+N problem. Anyone can write one MCP server for a data source (say, Postgres) once, following the spec. Anyone can write one MCP client for their app once. Any client can then talk to any server, because both sides speak the same protocol.
Without MCP: With MCP:
App A ---- GitHub integration App A --\
App A ---- Postgres integration MCP protocol --- GitHub server
App B ---- GitHub integration App B --/ --- Postgres server
App B ---- Postgres integration
What MCP actually standardizes
MCP defines:
- A base message protocol (JSON-RPC 2.0) for requests, responses, and notifications.
- A lifecycle: how a client and server discover each other's capabilities and agree on a protocol version.
- A small set of primitives — tools, resources, and prompts on the server side; sampling, roots, and elicitation on the client side. Together they cover most of what an LLM integration needs.
- Transports (stdio for local processes, Streamable HTTP for remote services). The same protocol works whether the server is a subprocess on your laptop or a hosted web service.
Why this matters in practice
A tool vendor (say, a project-management SaaS) can ship one official MCP server covering this list, once. Every AI app that supports MCP — Claude Desktop, Claude Code, various IDEs and agent frameworks — connects to it right away:
- No vendor-specific glue code on either side.
- No re-implementing the same integration for every new host application.
- No coordination needed between the server author and any particular client author.
Related Resources
It's easy to conflate "client" and "host." MCP draws a specific line between them.
The three roles
- Host: the application the user interacts with — Claude Desktop, Claude Code, a custom chatbot, an IDE plugin. The host holds the LLM, the conversation, and the UI. It decides which servers to connect to. It mediates every permission the user grants.
- Client: a component living inside the host that speaks MCP to exactly one server. A client is 1:1 with a server connection. It owns that connection's session state, capability negotiation, and message routing. It never talks to any other server.
- Server: a program exposing tools, resources, and/or prompts through MCP. A server has no idea what host it's connected to, or how many other servers that host also talks to. It only knows its one client.
Why 1:1, not 1:many
Host (Claude Desktop)
├── Client A ──(stdio)── Server A (filesystem)
├── Client B ──(stdio)── Server B (git)
└── Client C ──(HTTP)──── Server C (remote Slack MCP server)
Each client-server pair is isolated. This matters for security. A compromised or misbehaving server can only touch the data the host chose to expose through its own client. It can't reach into another server's session or eavesdrop on another connection. The host is the trust boundary. It aggregates results from multiple servers before handing them to the LLM. It's also where user consent for tool calls and data access should be enforced.
Practical shape
In code, this usually means: the host process spawns (or connects to) N server processes/endpoints, and creates N client objects, one per server. Each client independently runs the initialize handshake and independently tracks that server's tools, resources, and prompts.
Related Resources
MCP doesn't invent its own message format. It's layered directly on JSON-RPC 2.0, a lightweight, transport-agnostic RPC spec that predates MCP by over a decade.
The three message shapes
// Request — has an id, expects a response
{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "search", "arguments": {"q": "mcp"}}}
// Response — matches the request's id, has either "result" or "error", never both
{"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", "text": "3 results found"}]}}
// Notification — no id, fire-and-forget, no response expected
{"jsonrpc": "2.0", "method": "notifications/tools/list_changed"}
What JSON-RPC gives MCP for free
- A defined error object shape (
code,message, optionaldata) and a standard set of error codes (parse error, invalid request, method not found, invalid params, internal error). - Correlation of responses to requests via
id. This matters once you have several in-flight requests over one connection — an HTTP transport handling concurrent tool calls, for example. - A format that's transport-independent. The exact same JSON-RPC messages work whether they're framed as newline-delimited JSON over stdio, or as HTTP POST bodies/SSE events over Streamable HTTP.
What MCP adds on top
JSON-RPC only defines the envelope. On top of it, MCP layers everything that actually makes it MCP:
- A fixed catalog of method names (
initialize,tools/list,tools/call,resources/read,prompts/get,sampling/createMessage, and more). - The exact shape of each method's params and result.
- A lifecycle (initialize/initialized) that must run before most other methods are valid.
- A capabilities object that lets each side declare which optional features it supports.
Related Resources
The handshake exists so neither side has to guess what the other supports. It's negotiated explicitly, once, up front.
The three steps
// 1. Client -> Server
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18",
"capabilities": {"roots": {"listChanged": true}, "sampling": {}},
"clientInfo": {"name": "example-host", "version": "1.0.0"}
}}
// 2. Server -> Client
{"jsonrpc": "2.0", "id": 1, "result": {
"protocolVersion": "2025-06-18",
"capabilities": {"tools": {"listChanged": true}, "resources": {"subscribe": true}},
"serverInfo": {"name": "example-server", "version": "0.4.2"}
}}
// 3. Client -> Server (notification, no response)
{"jsonrpc": "2.0", "method": "notifications/initialized"}
Why each step matters
- Version negotiation: the client proposes the newest protocol version it understands. The server either agrees, or replies with an older version it can actually support. If the client can't work with the server's chosen version, it should close the connection instead of proceeding with mismatched assumptions.
- Capability negotiation: capabilities aren't just "yes/no, tools supported." They carry sub-flags too, like
listChanged(can this side send list-changed notifications for this primitive?) orsubscribe(can resources be subscribed to?). Each side only uses features the other side actually declared. - The
initializednotification tells the server the client has processed its capabilities and is ready for normal traffic. Servers shouldn't push notifications, like a resource update, before receiving it.
What's off-limits before this completes
Sending tools/call, resources/read, or any other operational request before the handshake finishes is invalid. The server is entitled to reject or ignore it. The only messages allowed to cross the wire before initialized are the handshake messages themselves, plus logging in practice.
Related Resources
MCP's design leans on one idea: different kinds of context need different kinds of control. That's why it splits functionality into distinct primitives instead of one generic "capability" bucket.
Server-side primitives
| Primitive | Who decides to use it | Typical shape |
|---|---|---|
| Tools | The model — the LLM picks a tool and arguments based on the conversation | A function with a name, description, and JSON Schema input, e.g. send_email(to, subject, body) |
| Resources | The application/host — it decides what context to attach, often without the model asking | A URI-addressable piece of data, e.g. file:///project/README.md or postgres://db/customers/42 |
| Prompts | The user — explicitly selected, often surfaced as a slash command or menu item | A parameterized message template, e.g. /summarize-pr number=482 |
Client-side primitives
| Primitive | Direction | Purpose |
|---|---|---|
| Sampling | Server asks client | Lets a server request an LLM completion through the client's model, instead of needing its own API key or model access |
| Roots | Client tells server | Scopes a server's filesystem/URI access to specific directories the user has exposed |
| Elicitation | Server asks client (asks user) | Lets a server pause mid-task and request extra structured input from the user, e.g. a missing parameter |
Why the distinction matters
If everything were "just a tool," a host would have no principled way to tell these apart: a destructive action that needs explicit user approval, background context that can be attached silently, or a workflow that only runs when a human deliberately invokes it. By baking the control model into the primitive itself, MCP pushes hosts toward safer defaults. It doesn't leave permissioning entirely up to each app author's judgment.