Free demoJob-oriented courses at Debugshala, Old Palasia, Indore. Freshers, non-IT and year-gap welcome.Book a free demo +91-8982385539

MCP Architecture: Hosts, Clients, Servers and Transports

Lesson 3 of 10 · 11 min · Updated 13 Nov 2026

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.

MCP architecture diagram: a host with an LLM and three MCP clients, each connected to one MCP server, one over stdio as a local process and two over Streamable HTTP to remote servers

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.

Python
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?

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:

JSON
{
  "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.

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

Sequence diagram comparing the legacy MCP session flow with initialize and initialized against the stateless 2026-07-28 flow where every request carries _meta and the server may reply input_required

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?

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.

Python
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?

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 Origin header to block DNS rebinding, as MCP security explains.
  • Copying initialize code 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/discover for up-front details.
  • Servers ask for input by returning input_required; clients retry with answers and a new ID.
  • Expect legacy initialize code in older material and let your SDK handle both eras.
Take the free Model Context Protocol (MCP) quiz10 quick questions with explanations. No sign-up needed, and you can come back to this lesson after.

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

Why MCP Was Created: Problems Before Model Context ProtocolMCP Tools, Resources and Prompts: The Three Server Primitives