MCP Architecture: Hosts, Clients, Servers and Transports
MCP architecture in depth: host, client and server roles, the data and transport layers, stateless requests in spec 2026-07-28, stdio vs Streamable HTTP.
Picture a large hotel in Vijay Nagar. The guest speaks only to the front desk. Behind the desk sit three phones, each a direct line to one outside vendor: the cab company, the restaurant and the laundry.
Every order slip the desk sends is complete on its own. It carries the room number, the guest's language and what they want. Any vendor employee on shift can handle it without asking "who are you again?".
When the restaurant needs a choice made, it does not ring the guest's room. It sends the slip back with a question, and the desk returns the slip with the answer filled in.
That is MCP's architecture in the current revision. The introduction to MCP named the roles. This article shows how they fit together and how messages actually travel.
What is MCP architecture?
Map the hotel to MCP. The guest is the user. The front desk is the host, the AI application that owns the model and the user's trust. Each phone line is an MCP client, one connection to one server.
Each vendor is an MCP server. The self-contained order slip is a request carrying its own _meta. The slip sent back with a question is a multi round-trip request.
MCP architecture is a client-server design in which a host application runs one MCP client per MCP server, and each client exchanges JSON-RPC 2.0 messages with its server over a transport such as stdio or Streamable HTTP, with every request self-contained.
This article targets MCP specification 2026-07-28, the latest revision as of November 2026. Material written before mid-2026 describes the older session-based model, covered in the last main section.
One host, three clients, three servers. The transport differs per server; the messages do not.
Who does what: host, client and server
The three roles have sharp boundaries, and most design mistakes come from blurring them.
| Role | Owns | Does not do |
|---|---|---|
| Host | The model, the conversation, user consent, which servers are connected | Speak to tools directly |
| Client | One connection: framing, request IDs, retries, caching that server's lists | Decide what the model should do |
| Server | One tool domain: its tools, resources and prompts, plus its own credentials | See the conversation or other servers |
The isolation is deliberate. A calendar server never sees what your database server returned, so one bad server cannot read everything. What servers actually offer is covered in MCP tools, resources and prompts.
A coding assistant might run local file-system and git servers as subprocesses and also reach a remote issue tracker over HTTPS.
The two layers of MCP: data and transport
MCP separates what is said from how it travels.
The data layer is the protocol itself: JSON-RPC 2.0 requests, results, errors and notifications, plus the methods tools/list, tools/call, resources/read, prompts/get, server/discover and subscriptions/listen.
The transport layer moves those messages. The spec defines two standard transports, and you can build custom ones as long as the JSON-RPC format and message patterns stay the same.
stdio transport
The host launches the server as a child process. The client writes requests to the server's standard input and reads responses from its standard output, one JSON message per line. Messages must not contain embedded newlines, and the server must never print anything to stdout that is not an MCP message. Logs go to stderr.
That last rule causes the most common beginner bug: a stray print("server started") corrupts the stream. The simulation below shows the framing and why it breaks.
import io, json
def send(stream, message):
stream.write(json.dumps(message) + "\n") # json.dumps never emits raw newlines
def read_all(stream):
stream.seek(0)
good, bad = [], []
for line in stream:
try:
good.append(json.loads(line)["id"])
except json.JSONDecodeError:
bad.append(line.strip())
return good, bad
stdout = io.StringIO() # pretend this is the server's stdout pipe
send(stdout, {"jsonrpc": "2.0", "id": 1, "result": {"resultType": "complete", "tools": []}})
stdout.write("server started on stdio\n") # the classic bug: a print() to stdout
send(stdout, {"jsonrpc": "2.0", "id": 2, "result": {"resultType": "complete", "content": []}})
ids, garbage = read_all(stdout)
print("Responses the client could parse:", ids)
print("Lines that broke the protocol:", garbage)To shut down, the client closes the server's stdin and waits; the server should exit when it reads end-of-file. If the process crashes, the client simply restarts it and retries, which is safe because no session state was lost.
Streamable HTTP transport
For remote servers, the server exposes one endpoint, such as https://example.com/mcp. Every client message is its own HTTP POST. The server answers with either a single JSON body or a Server-Sent Events stream scoped to that one request, which can carry progress notifications before the final result.
Each POST must include an MCP-Protocol-Version header that matches the version in the body, plus Mcp-Method and, for tool calls and reads, Mcp-Name. These copies of body fields let load balancers and gateways route requests without parsing JSON. If header and body disagree, the server rejects the request with a HeaderMismatch error (-32020).
| stdio | Streamable HTTP | |
|---|---|---|
| Where the server runs | Child process on the same machine | Anywhere on the network |
| Framing | One JSON message per line | One HTTP POST per message |
| Streaming | Shared stdout | SSE stream per request |
| Cancellation | notifications/cancelled |
Close that request's stream |
| Credentials | Environment variables | OAuth 2.1 bearer token per request |
| Typical use | Local files, local git, dev tools | Shared company services, SaaS |
Quick check
A stdio server prints "ready" to stdout. What happens?
On stdio, stdout is reserved for MCP messages. Logs belong on stderr.
How a request travels in the stateless model
In 2026-07-28 there is no handshake. A client can send tools/list as its very first message, as long as params._meta carries the required fields:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": { "name": "campus-assistant", "version": "2.0.0" }
}
}
}The server checks the envelope on every request. A missing required field gets -32602 (invalid params). An unknown version gets UnsupportedProtocolVersion (-32022) with a list of versions it does support, and the client retries with one of them. Every successful result includes resultType, and servers should name themselves in the result's _meta.
SUPPORTED = ["2026-07-28"]
def server_handle(req):
meta = req.get("params", {}).get("_meta", {})
need = ["io.modelcontextprotocol/protocolVersion", "io.modelcontextprotocol/clientCapabilities"]
if any(k not in meta for k in need):
return {"id": req["id"], "error": {"code": -32602, "message": "missing _meta fields"}}
version = meta[need[0]]
if version not in SUPPORTED:
return {"id": req["id"], "error": {"code": -32022, "message": "Unsupported protocol version",
"data": {"supported": SUPPORTED, "requested": version}}}
return {"id": req["id"], "result": {"resultType": "complete", "tools": [],
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "timetable", "version": "1.4"}}}}
def client_call(method, version, next_id=[1]):
while True:
req = {"jsonrpc": "2.0", "id": next_id[0], "method": method, "params": {"_meta": {
"io.modelcontextprotocol/protocolVersion": version,
"io.modelcontextprotocol/clientCapabilities": {}}}}
next_id[0] += 1
resp = server_handle(req)
print(f"id={req['id']} version={version} ->", resp.get("error", {}).get("code", "ok"))
if resp.get("error", {}).get("code") == -32022:
version = resp["error"]["data"]["supported"][0] # pick a mutually supported version
continue
return resp
print(client_call("tools/list", "2027-01-01"))
print(server_handle({"id": 99, "params": {}}))The client in this sketch asked for a future version, got told what the server supports and retried. No connection state was involved, so any server replica behind a load balancer could have answered either attempt.
server/discover exists for clients that want the version list, capabilities and server name up front, for example to show them in a settings screen. Servers must implement it; clients may skip it.
Left: the legacy session model. Right: the stateless model, where each request stands alone.
Quick check
In MCP 2026-07-28, where does a client put its protocol version?
The stateless revision removed the handshake, so each request carries its own version and capabilities.
When the server needs something: multi round-trip requests
Sometimes a tool cannot finish without the user. A booking tool may need the user to confirm a seat. In older versions the server sent its own request to the client mid-call.
Now servers never send requests. They return a result with resultType: "input_required", an inputRequests map and an opaque requestState, and the client retries the original call with inputResponses and a new request ID.
import json
def book_lab(params):
answers = params.get("inputResponses", {})
if "confirm" not in answers:
return {"resultType": "input_required",
"inputRequests": {"confirm": {"method": "elicitation/create", "params": {
"mode": "form", "message": "Book Lab 2 on Monday 6 pm?",
"requestedSchema": {"type": "object", "properties": {"ok": {"type": "boolean"}}}}}},
"requestState": "opaque-token-123"}
ok = answers["confirm"]["action"] == "accept" and answers["confirm"]["content"]["ok"]
return {"resultType": "complete", "isError": False,
"content": [{"type": "text", "text": "Booked" if ok else "Not booked"}]}
first = {"name": "book_lab", "arguments": {"lab": 2}}
r1 = book_lab(first)
print("id=1 ->", r1["resultType"], "| asks:", r1["inputRequests"]["confirm"]["params"]["message"])
user_reply = {"action": "accept", "content": {"ok": True}} # the host asked the user
retry = {**first, "inputResponses": {"confirm": user_reply}, "requestState": r1["requestState"]}
print("id=2 ->", json.dumps(book_lab(retry)))Because the state rides inside the retry, the server keeps nothing in memory between the two calls. A real server must also integrity-protect requestState, since it passes through the client and could be tampered with.
For changes the client wants to hear about, such as a server's tool list changing, the client opens one long-lived subscriptions/listen request and opts in to specific notification types. That stream replaced the separate HTTP GET channel of earlier versions.
Quick check
How does a 2026-07-28 server ask the user to confirm something during tools/call?
Multi round-trip requests replaced server-initiated requests.
Legacy sessions vs the stateless model
You will meet both in the wild, so learn to recognise them.
| Legacy (2025-11-25 and earlier) | Modern (2026-07-28) | |
|---|---|---|
| Start of conversation | initialize, then notifications/initialized |
Any request, with _meta |
| Version negotiation | Once per session | On every request |
| HTTP session | Mcp-Session-Id header |
None |
| Server needs input | Server sends its own request | input_required result, client retries |
| Change notifications | HTTP GET stream | subscriptions/listen |
| Cross-call state | Implicit in the session | Explicit handles passed as tool arguments |
A dual-era server answers both. On stdio, a client that supports both sends server/discover first and falls back to initialize if the server does not recognise it. The official Python SDK does this automatically, as the Python MCP server tutorial shows.
Common mistakes with MCP architecture
- Printing to stdout in a stdio server. Use a logger that writes to stderr.
- One client for many servers. Each server gets its own client so failures and permissions stay separate.
- Assuming the connection is a conversation. Requests from different chats can share one stdio process. Pass any state as an explicit argument.
- Exposing an HTTP server on 0.0.0.0 during development. Bind to 127.0.0.1 and validate the
Originheader to block DNS rebinding, as MCP security explains. - Copying
initializecode from old blog posts into a new client. Use an SDK that speaks 2026-07-28 and handles the fallback.
Key takeaways
- The host owns the model and consent; each client holds one connection; each server owns one tool domain.
- The data layer is JSON-RPC; the transport layer is stdio for local processes or Streamable HTTP for remote endpoints.
- MCP 2026-07-28 is stateless: per-request
_meta, no sessions,server/discoverfor up-front details. - Servers ask for input by returning
input_required; clients retry with answers and a new ID. - Expect legacy
initializecode in older material and let your SDK handle both eras.
FAQ
What is the difference between an MCP host and an MCP client?
The host is the whole AI application the user sees, including the model and the consent screens. A client is a small component inside the host that manages exactly one connection to one MCP server. A host connected to four servers runs four clients.
Does MCP still use an initialize handshake?
Not in the current revision. Specification 2026-07-28 removed initialize and sessions; every request carries its protocol version and capabilities in _meta. Servers from 2025-11-25 and earlier still use the handshake, and dual-era clients and servers support both.
Should I use stdio or Streamable HTTP for my MCP server?
Use stdio when the server runs on the user's own machine and needs local access, such as files or a local database. Use Streamable HTTP when many users or apps share one server, or when it wraps a cloud service. HTTP servers should require OAuth-based authorization.
Can one MCP server serve many clients at once?
Yes. A Streamable HTTP server handles many clients, and because the protocol is now stateless, you can run several replicas behind a load balancer. A stdio server is a child process of one host, though that host may send it requests from several conversations.
Where can I practise MCP and agent architecture in Indore?
If you want mentor guidance while you build projects like these, Debugshala's Generative AI classroom programme runs at the Old Palasia campus in English and Hindi, and starts with a free demo class. For quick revision of JSON and Python logic, work through coding quizzes on Debugshala Test.
From the team behind this site
Go further with the Generative AI Course at Debugshala
These tutorials are written by Debugshala, a training and placement institute at Old Palasia, Indore. The classroom course adds live mentoring, real projects and interview preparation.
- Offline classes at Old Palasia, Indore
- Free demo class before you pay
- Non-IT, fresher and year-gap friendly
- Placement assistance
700+ students placed · 200+ hiring partners · 4.9★ Google rating