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.

flowchart LR U[User] --> H subgraph H[Host application] direction TB M[LLM] C1[MCP client A] C2[MCP client B] end C1 <-->|stdio| S1[docs-assistant server] C2 <-->|Streamable HTTP| S2[Remote server] S1 --> D[(Docs index)] S2 --> A[(Internal API)]

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

-32020

HeaderMismatch

An HTTP header (Mcp-Method, Mcp-Name, Mcp-Param-*, MCP-Protocol-Version) is missing, malformed or disagrees with the request body.

-32021

MissingRequiredClientCapability

Serving the request needs a client capability that the request’s _meta did not declare; data.requiredCapabilities lists what is missing.

-32022

UnsupportedProtocolVersion

The server does not implement the protocol version the request asked for; data.supported lists the versions it does.

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

io.modelcontextprotocol/protocolVersion

Yes

The protocol version this request uses, for example "2026-07-28".

io.modelcontextprotocol/clientCapabilities

Yes

Only the client capabilities relevant to this request, for example {"elicitation": {"form": {}}}.

io.modelcontextprotocol/clientInfo

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

io.modelcontextprotocol/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

io.modelcontextprotocol/ui

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

io.modelcontextprotocol/skills

Publishes Agent Skills — a directory with a SKILL.md and supporting files — through the existing resources primitive, so a client can discover a workflow’s instructions from the server that owns the service. Client support is still being implemented, as its own overview page states.

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 _meta plus the optional server/discover;

  • the session header and protocol sessions were removed;

  • the GET stream and resources/subscribe were replaced by an explicit subscriptions/listen request;

  • server-initiated requests were replaced by multi-round-trip requests, where the server returns an input_required result and the client retries with the answers;

  • ping, logging/setLevel, notifications/roots/list_changed and 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

sequenceDiagram participant C as MCP client participant S as docs-assistant server C->>S: server/discover (_meta: protocolVersion, clientCapabilities, clientInfo) S-->>C: supportedVersions, capabilities, serverInfo, ttlMs, cacheScope C->>S: tools/list S-->>C: search_docs (inputSchema, outputSchema, annotations) C->>S: tools/call search_docs (query, component, top_k) S-->>C: content + structuredContent, resultType complete

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": { …​ }.

Step 1 — discover
{
  "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" }
    }
  }
}
Step 2 — list the tools
{ "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.

Step 3 — call the tool
{
  "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.

Python client (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.

Java client (MCP Java SDK 2.0.1, protocol 2025-11-25)
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.

TypeScript client (@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.