MCP Architecture
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against the 2026-07-28 revision of
the specification, the Python mcp SDK 2.2.0, the TypeScript @modelcontextprotocol/client 2.2.0 and the MCP Java
SDK 2.0.1 with Spring AI 2.0.1, as checked on 2026-09-29 (see the Model Context Protocol
baseline). Python examples target Python 3.12, Java examples Java 21 and Spring Boot 4.1, and TypeScript examples
Node.js 24. The Java SDK and Spring AI speak protocol 2025-11-25 only: they have no server/discover and no
multi-round-trip requests, and they answer a modern client’s initialize with 2025-11-25. Where that matters the
page says so.
The Model Context Protocol (MCP) is a small protocol: JSON-RPC 2.0 messages carried over a transport, a handful of
server features (tools, resources, prompts), one client feature that matters in practice (elicitation), and a way to
negotiate optional extensions. The 2026-07-28 revision made it much smaller at runtime by removing the connection
state around those messages. This page explains the moving parts and then walks one complete exchange — server/discover, tools/list, tools/call — against the running example, the docs-assistant server that the
rest of the sub-section builds in three stacks.
Hosts, clients and servers
MCP separates three roles. The host is the application the user talks to — an IDE, a chat application or an agent runtime such as Claude Code, VS Code with Copilot or a custom LangChain agent (see Connecting Hosts). The host owns the conversation with the model, decides which tools the model may see, shows confirmation prompts, and enforces security boundaries. The client is the protocol component inside the host that speaks to exactly one server; a host with three configured servers runs three clients. The server is the program that exposes tools, resources and prompts — a local subprocess or a remote HTTP service.
The design goals in the specification’s architecture page follow from this split: servers should be easy to build and focused on one capability, they should compose, and a server must not be able to read the whole conversation or "see into" another server. The host keeps the full conversation and mediates every cross-server interaction, so a server only ever receives the arguments of the request addressed to it. That is why tool descriptions returned by a server are treated as untrusted input by careful hosts (see MCP Security).
Two roles are easy to confuse with the above. The model never speaks MCP: the host translates tool definitions into
the model provider’s function-calling format (see
Structured Output & Tool Calling) and turns the
model’s tool calls back into tools/call requests. And an agent framework such as LangChain is usually the host:
it embeds one or more MCP clients (see Building MCP Clients) and feeds the tools
into its agent loop (see Agent Loop & Architectures).
JSON-RPC 2.0
Every MCP message is a JSON-RPC 2.0 object. A request carries jsonrpc,
id, method and params; the receiver answers with a response carrying the same id and either result or
error. A notification has no id and gets no reply. The transport decides how messages travel (see
Transports); the message shapes are the same everywhere.
Failures come in two layers. A protocol-level failure is a JSON-RPC error object with a numeric code: the
standard codes (-32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params,
-32603 internal error) plus, in 2026-07-28, a range the specification reserves for itself, -32020 to -32099,
of which three codes are allocated so far:
| Code | Name | Raised when |
|---|---|---|
|
HeaderMismatch |
An HTTP header ( |
|
MissingRequiredClientCapability |
Serving the request needs a client capability that the request’s |
|
UnsupportedProtocolVersion |
The server does not implement the protocol version the request asked for; |
The second layer is the tool result: a tool that ran and failed reports isError: true inside a normal result,
which is covered on Tools, Resources, Prompts & Elicitation.
The stateless core
Before 2026-07-28 every connection began with an initialize request in which client and server exchanged protocol
versions, capabilities and identities, followed by an initialized notification. The server then held that agreed
state — and, on HTTP, an Mcp-Session-Id — for the life of the connection. The 2026-07-28 revision removes the
handshake, protocol-level sessions and the session header. Nothing about a request depends on an earlier request:
each one is self-describing.
The mechanism is the _meta object that already existed on every request. Three keys in the reserved
io.modelcontextprotocol/ namespace now carry what the handshake used to establish:
_meta key |
Required | Meaning |
|---|---|---|
|
Yes |
The protocol version this request uses, for example |
|
Yes |
Only the client capabilities relevant to this request, for example |
|
No (SHOULD) |
Client name and version, for display and logging. |
A request that omits a required key is malformed: the server answers -32602, and on HTTP with status 400. The
server identifies itself the same way in the other direction, in every result’s _meta under
io.modelcontextprotocol/serverInfo. Both info objects are self-reported and unverified, so the specification tells
implementations not to base behaviour or security decisions on them.
A version mismatch is handled per request rather than negotiated once. If the server does not implement the version a
request declares, it returns UnsupportedProtocolVersion (-32022) with the versions it does support, and the client
retries with one of them:
{
"jsonrpc": "2.0",
"id": 7,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": { "supported": ["2026-07-28"], "requested": "2099-01-01" }
}
}
That response is real output from the Python docs-assistant server when it was sent a request declaring
2099-01-01. The practical effect of statelessness is that any server instance can answer any request. There is no
session to replicate or pin, which is what allows plain round-robin load balancing (see
Transports and Deploying Remote Servers).
Anything a server needs to remember between two requests — the classic example is the state of a multi-step
elicitation — travels inside the messages themselves (requestState, covered on
Tools, Resources, Prompts & Elicitation).
server/discover
server/discover is how a client asks a server, before anything else, which protocol versions it supports, which
capabilities it declares and who it is. Servers must implement it. Clients may call it but do not have to: a client
can send any request inline and react to UnsupportedProtocolVersion if its preferred version is not supported.
Discovery earns its keep in two situations. A client that wants to show what a server offers can do so from one
response instead of probing tools/list, prompts/list and resources/list separately, and a client on stdio that
supports both eras uses it as the probe that decides between the modern and the legacy protocol, because stdio has no
HTTP status code to trigger a fallback.
The result carries supportedVersions, capabilities, optional natural-language instructions for the model, and the
serverInfo in _meta. Like the list results it is cacheable, so it also carries ttlMs and cacheScope (see
Tools, Resources, Prompts & Elicitation). Capabilities are declared per feature; the
docs-assistant server advertises tools, resources and prompts, each with listChanged, and resources with
subscribe.
Extensions
Everything beyond the core is negotiated as an extension. Both sides advertise the extensions they support in the
extensions field of their capabilities, a map from an extension identifier to that extension’s settings object. An
identifier follows the _meta naming rules — a mandatory vendor prefix, a slash, a name — so official extensions
live under io.modelcontextprotocol/ and third parties use a reversed domain they own, such as com.example/….
If only one party supports an extension, the other side reverts to core behaviour or rejects the request; an empty
settings object means "supported, no options". Extensions are optional, additive, and versioned independently of the
core.
Three official extensions matter for the rest of this sub-section:
| Extension | Identifier | What it adds |
|---|---|---|
Tasks |
|
Durable handles for long-running operations: a server returns a task instead of blocking, and the client polls for progress, supplies input when needed and fetches the result after reconnecting. In earlier revisions tasks were part of the core; 2026-07-28 moved them into this extension. |
MCP Apps |
|
Lets a server return interactive user interface (charts, forms, media players) that a conversational client renders inline. Building such interfaces is outside this sub-section; the extension’s own documentation is the pointer. |
Skills over MCP |
|
Publishes Agent Skills — a directory with a |
Because extension settings ride in clientCapabilities on every request, an extension is negotiated per request like
everything else. Authorization extensions (client credentials, enterprise-managed authorization) follow the same
mechanism and are mentioned on Authorization.
Observability: trace context in _meta
The keys traceparent, tracestate and baggage are reserved in _meta for OpenTelemetry context propagation,
with values in the W3C Trace Context and Baggage formats. A host that already has an active span puts its traceparent
into the request’s _meta, and a server that instruments its handlers continues the same trace:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "search_docs",
"arguments": { "query": "matrix decomposition", "component": "algebra" },
"_meta": {
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"
}
}
}
The value shown is the specification’s own example. Since there is no connection identity to correlate on any more, this is the supported way to follow one user action from the host through the MCP server into the systems behind it. See Testing & Debugging for how it fits with logs, and the OpenTelemetry semantic conventions for MCP for span names and attributes.
What changed from initialize and sessions
This subsection is history, because most books, blog posts and older SDK versions describe the previous design and you
will meet it in real deployments. Protocol versions 2025-11-25 and earlier — the specification calls them the legacy
era — worked like this. The transport was first the HTTP+SSE pair of 2024-11-05, then Streamable HTTP from
2025-03-26. A client opened a connection and sent initialize; the server replied with its version, capabilities and
info; the client confirmed with a notification; and only then could requests flow. On HTTP the server could mint an
Mcp-Session-Id that the client had to echo, a client could open a long-lived GET stream to receive
server-initiated messages, servers could send their own JSON-RPC requests (sampling, elicitation, roots) down a stream,
and streams could be resumed with Last-Event-ID. Every one of those mechanisms needs state that lives in one server
process, which is why deployments needed sticky routing or a shared session store.
Revision 2026-07-28 — the modern era — replaced them piece by piece:
-
the handshake became per-request
_metaplus the optionalserver/discover; -
the session header and protocol sessions were removed;
-
the
GETstream andresources/subscribewere replaced by an explicitsubscriptions/listenrequest; -
server-initiated requests were replaced by multi-round-trip requests, where the server returns an
input_requiredresult and the client retries with the answers; -
ping,logging/setLevel,notifications/roots/list_changedand SSE resumability were removed, and roots, sampling and logging were deprecated.
The specification defines dual-era implementations that speak both, and gives a fallback rule: a client tries a
modern request first and, if the server answers with something that is not a recognised modern JSON-RPC error, falls
back to initialize. The official SDKs implement that rule for you. The Python Client defaults to mode="auto",
which probes server/discover and falls back to the handshake on legacy servers. The TypeScript client implements
the same rule during connect(), but only when asked: its versionNegotiation option defaults to 'legacy' (the
plain initialize sequence), so pass { mode: 'auto' } to opt in. The Java SDK 2.0.x has not adopted the modern era: it always performs initialize, and a
Spring AI 2.0.1 server answers a 2026-07-28 client’s initialize with 2025-11-25. Servers built with it are still
reachable from current hosts through the fallback, but they cannot offer multi-round-trip elicitation (see
Spring Boot MCP Servers).
One exchange: discover, list, call
Each request below is an independent HTTP POST to the same endpoint; nothing links them but the client’s own logic.
The messages are trimmed from a real session against the Python docs-assistant server started with
python server.py streamable-http (the complete transcript, with headers, is on
Transports). Every request also carries the _meta block shown in the first one;
later requests abbreviate it as "_meta": { … }.
{
"jsonrpc": "2.0",
"id": 1,
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": { "elicitation": { "form": {} } },
"io.modelcontextprotocol/clientInfo": { "name": "curl-demo", "version": "0.1.0" }
}
}
}
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": {
"tools": { "listChanged": true },
"resources": { "listChanged": true, "subscribe": true },
"prompts": { "listChanged": true }
},
"ttlMs": 0,
"cacheScope": "private",
"_meta": {
"io.modelcontextprotocol/serverInfo": { "name": "docs-assistant", "version": "1.0.0" }
}
}
}
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": { "_meta": { ... } } }
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"tools": [
{
"name": "search_docs",
"description": "Search the documentation of one component and return the best matching pages.",
"annotations": { "readOnlyHint": true, "openWorldHint": false },
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" },
"component": { "anyOf": [{ "type": "string" }, { "type": "null" }], "default": null },
"top_k": { "type": "integer", "default": 3 }
},
"required": ["query"]
},
"outputSchema": { "type": "object", "properties": { "query": {}, "component": {}, "hits": {} } }
}
],
"ttlMs": 0,
"cacheScope": "private"
}
}
The title keywords the Python SDK generates and the $defs block of the full outputSchema are omitted here; the
whole document is on Tools, Resources, Prompts & Elicitation.
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "search_docs",
"arguments": { "query": "matrix decomposition", "component": "algebra", "top_k": 2 },
"_meta": { ... }
}
}
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"resultType": "complete",
"isError": false,
"content": [{ "type": "text", "text": "{ \"query\": \"matrix decomposition\", ... }" }],
"structuredContent": {
"query": "matrix decomposition",
"component": "algebra",
"hits": [
{ "component": "algebra", "page": "matrices", "title": "Matrices", "score": 2,
"snippet": "Matrix decompositions such as LU, QR and SVD solve linear systems." },
{ "component": "algebra", "page": "wrappers", "title": "Wrappers", "score": 1,
"snippet": "Wrapper classes hide the decomposition details behind a simple solver." }
]
}
}
}
resultType: "complete" is on every final result; the alternative, input_required, is what a server returns when it
needs the client to answer something first. The text block duplicates structuredContent for clients that only read
content.
The same exchange with an SDK
No SDK makes you build those messages by hand. Each snippet below connects, lists the tools and calls search_docs
against the corresponding docs-assistant server, and was run against it.
mcp 2.2.0, mode="auto")import asyncio
import mcp_types as types
from mcp import Client
from mcp.client.session import ClientRequestContext
async def choose(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
# only called if the server needs more input; here we always pick "algebra"
return types.ElicitResult(action="accept", content={"component": "algebra"})
async def main() -> None:
async with Client("http://127.0.0.1:8000/mcp", elicitation_callback=choose) as client:
print(client.protocol_version) # 2026-07-28, learned via server/discover
tools = await client.list_tools() # tools/list
print([t.name for t in tools.tools])
result = await client.call_tool("search_docs", {"query": "matrix"}) # tools/call
print(result.structured_content["component"], len(result.structured_content["hits"]))
asyncio.run(main())
The call omits component, so this server answers with an input_required result; call_tool runs the
retry loop and invokes choose for you. The program printed 2026-07-28, ['search_docs'] and algebra 1. See the
Python SDK documentation.
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
import java.util.Map;
public class DocsClientDemo {
public static void main(String[] args) {
var transport = HttpClientStreamableHttpTransport.builder("http://127.0.0.1:8080").endpoint("/mcp").build();
try (McpSyncClient client = McpClient.sync(transport).build()) {
client.initialize(); // legacy era: initialize handshake, no server/discover
McpSchema.ListToolsResult tools = client.listTools(); // tools/list
tools.tools().forEach(t -> System.out.println(t.name()));
McpSchema.CallToolResult result = client.callTool( // tools/call
McpSchema.CallToolRequest.builder("search_docs")
.arguments(Map.of("query", "matrix decomposition", "component", "algebra", "topK", 2))
.build());
System.out.println(result.structuredContent());
}
}
}
This is the one place where the wire exchange is different: the Java SDK opens with initialize (the server replied
with protocol 2025-11-25) and the Spring docs-assistant takes component as a required argument, because it has
no elicitation. It compiled with JDK 25 against the SDK’s published API and ran against the Spring Boot server, printing
search_docs and the structuredContent map. See the MCP Java SDK
documentation.
@modelcontextprotocol/client 2.2.0)import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const client = new Client(
{ name: 'docs-client', version: '1.0.0' },
{ versionNegotiation: { mode: 'auto' } } // probe server/discover, fall back to initialize (default is 'legacy')
);
await client.connect(new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3000/mcp')));
console.log(client.getServerVersion()); // { name: 'docs-assistant', version: '1.0.0' }
const { tools } = await client.listTools(); // tools/list
console.log(tools.map(t => t.name));
const result = await client.callTool({ // tools/call
name: 'search_docs',
arguments: { query: 'matrix decomposition', component: 'algebra', top_k: 2 }
});
console.log(JSON.stringify(result.structuredContent));
await client.close();
It type-checks with tsc --noEmit (TypeScript 7.0.2) and printed the server info, [ 'search_docs' ] and the
structuredContent shown above. See the TypeScript SDK documentation.
The three servers behind these clients are built in Python MCP Servers, Spring Boot MCP Servers and Node.js MCP Servers; clients get their own page in Building MCP Clients.