The MCP Architecture
The data layer vs transport layer split, and the stateless per-request model that replaced the old initialize handshake in spec 2026-07-28
The MCP Architecture
TL;DR
MCP splits into a data layer (JSON-RPC 2.0 messages — tools, resources, prompts, notifications) and a transport layer (how those messages actually travel). As of spec version 2026-07-28, the protocol is stateless: every request carries its own protocol version, capabilities, and identity in a _meta field, replacing the old model of a single upfront initialize handshake that a session then relied on.
| Property | Value |
|---|---|
| Level | Intermediate |
| Reading time | ~20 minutes |
| Prerequisites | What Is MCP? |
| You will understand | The two-layer architecture, and exactly how the stateless per-request model works |
Two Layers, One Message Format
MCP's two layers
Data layer
Transport layer
The data layer is the inner layer — it defines what a message means: this is a tool call, this is a resource read, this is a notification that something changed. The transport layer is the outer layer — it defines how that message physically gets from one process to another, whether that's stdin/stdout pipes on the same machine or an HTTP request across the internet.
This separation is why the same JSON-RPC message format works identically over both transports. A tools/call request looks exactly the same whether it travels over stdio or Streamable HTTP — only the delivery mechanism differs, never the message shape.
The Architecture in a Real Example
Visual Studio Code acting as a host makes this concrete. When VS Code connects to the Sentry MCP server, its runtime instantiates one MCP client object dedicated to that connection. When VS Code separately connects to a local filesystem server, it instantiates a second, independent client object.
VS Code (host)
├── MCP Client 1 ──dedicated connection──> Sentry server (remote, Streamable HTTP)
└── MCP Client 2 ──dedicated connection──> Filesystem server (local, stdio)Local servers using stdio typically serve exactly one client — the process was launched by, and talks only to, the host that spawned it. Remote servers using Streamable HTTP typically serve many clients simultaneously — many different hosts, or many users of the same host, can all reach the same hosted server.
The Big Change: Statelessness
This is the single most important architectural fact in the current spec
Older descriptions of MCP — including a lot of content written before mid-2026 — describe a session that begins with an initialize request, after which the server remembers the negotiated capabilities for the rest of the connection. That model is gone as of spec 2026-07-28. MCP is now explicitly stateless: every request is self-contained.
Every request carries the protocol version and the capabilities relevant to that specific request in a _meta field, so the server can process each request entirely on its own — without inferring anything from what came before. Clients should also identify themselves in _meta, unless configured not to.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "weather_current",
"arguments": { "location": "San Francisco" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "example-client", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": { "elicitation": {} }
}
}
}_meta field | Carries |
|---|---|
io.modelcontextprotocol/protocolVersion | The spec version this specific request speaks |
io.modelcontextprotocol/clientInfo | The client's name and version, for identification and debugging |
io.modelcontextprotocol/clientCapabilities | Which primitives this client can handle — e.g. {"elicitation": {}} |
Why this matters practically: a stateless protocol means no connection-level assumption can silently go stale. A server doesn't need to remember what a client claimed three requests ago — it re-reads the client's capabilities on every single request, which makes reconnects, load-balanced remote servers, and crash recovery all dramatically simpler than a model that depends on in-memory session state surviving.
server/discover: Optional, But Useful
A client that wants to learn what a server supports before sending anything else can send a server/discover request. It's not mandatory — a client is free to send any request directly and simply handle a version error if the server doesn't support it — but it's a convenient way to fetch identity, capabilities, and supported versions in one round trip.
// Request
{
"jsonrpc": "2.0", "id": 1, "method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "example-client", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": { "elicitation": {} }
}
}
}
// Response
{
"jsonrpc": "2.0", "id": 1,
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": { "tools": { "listChanged": true }, "resources": {} },
"_meta": {
"io.modelcontextprotocol/serverInfo": { "name": "example-server", "version": "1.0.0" }
},
"ttlMs": 3600000,
"cacheScope": "public"
}
}Reading the response field by field:
| Field | Meaning |
|---|---|
supportedVersions | Every protocol version this server accepts |
capabilities | Which primitives the server supports, and their sub-features — tools.listChanged: true means the server can notify when its tool list changes |
io.modelcontextprotocol/serverInfo | The server's own name and version, for debugging |
ttlMs | A freshness hint in milliseconds — how long this response can be cached before re-fetching |
cacheScope | Who may reuse this cached response — "public" means broadly cacheable |
If a server doesn't support the version a client requested, it rejects with an UnsupportedProtocolVersionError listing the versions it does support. The client then retries with a mutually supported version. This is the entire version-negotiation story — there's no separate negotiation phase, just a request, and a possible retry with a different declared version.
JSON-RPC 2.0: The Base Protocol
Every MCP message — request, response, or notification — is a JSON-RPC 2.0 message. Two properties of JSON-RPC matter for understanding MCP's behavior:
Requests vs notifications
Requests carry an `id`
A request like tools/call expects a matching response, correlated by the id field. This is how a client knows which response belongs to which request when several are in flight.
Notifications carry no `id`
A notification like notifications/tools/list_changed expects no response at all — it's fire-and-forget by design. If you see a JSON-RPC message with no id field, that's the signal it's a notification, not a request.
Because every request is self-contained (per the statelessness model above) and JSON-RPC's request/notification distinction is unambiguous from the message shape alone, a server never has to guess what kind of message it just received or what state it's supposed to already know.
Concept Checks
Check yourself
Why can't a server rely on remembering a client's capabilities from an earlier request, the way older MCP descriptions assume?
Because as of spec 2026-07-28 the protocol is stateless — there is no upfront session handshake for the server to remember the results of. Every request carries its own _meta fields (protocol version, capabilities, client identity), so the server re-reads what it needs to know on every single request rather than depending on in-memory state from a prior exchange.
Is calling `server/discover` required before a client can send its first real request, like `tools/list`?
No. Discovery is optional. A client can send tools/list or any other request directly; if the server doesn't support the requested protocol version, it responds with an UnsupportedProtocolVersionError and the client retries with a version the server does support. server/discover exists as a convenient, cacheable way to learn a server's capabilities up front — it isn't a mandatory handshake step.
A `server/discover` response includes `ttlMs: 3600000` and `cacheScope: 'public'`. What does that tell a client?
That the discovery result can be treated as valid and reused for up to 3,600,000 milliseconds (one hour) without re-fetching, and that this cached result may be shared broadly (public scope) rather than kept per-client-only. This is what lets a client avoid re-running discovery on every single interaction with a server it's already talked to recently.
Key Concepts Recap
| Concept | One-line summary |
|---|---|
| Data layer | JSON-RPC 2.0 messages defining tools, resources, prompts, notifications |
| Transport layer | How messages travel — stdio or Streamable HTTP |
| Statelessness (2026-07-28) | Every request is self-contained; no reliance on a prior handshake |
_meta fields | protocolVersion, clientInfo, clientCapabilities — carried on every request |
server/discover | Optional, cacheable request to learn a server's capabilities up front |
UnsupportedProtocolVersionError | What a server returns when it can't speak the requested version |
ttlMs / cacheScope | How long, and how broadly, a discovery response can be cached |
| JSON-RPC requests vs notifications | Requests carry an id and expect a response; notifications carry neither |
Next
With the architecture and message model established, look at the first and most important primitive: Tools.