← Back to Agentic AI map
Lesson 4.2 · Model Context Protocol (MCP)

MCP Architecture

Look inside MCP: the host, its clients and servers; the three things a server offers (tools, resources, prompts); the JSON messages on the wire - recorded from real runs; the two transports, stdio and HTTP; and the two kinds of error.

mcp

What you will be able to do

  • Draw how a host, its clients and servers fit together
  • Explain tools, resources and prompts - and who decides when each is used
  • Read real MCP messages: requests, responses and notifications in JSON-RPC 2.0
  • Explain the two ways a connection starts: the initialize handshake, and the newer per-request style
  • Choose a transport: stdio or Streamable HTTP
  • Tell a protocol error from a tool error

The idea, in plain English

Lesson 4.1 showed that MCP lets any app use any tool. Now we open the box. What exactly travels between a client and a server? What can a server offer besides tools? How do the two programs connect?

The good news: MCP is small. Every message is a short piece of JSON. A server offers three kinds of things. And there are two ways to connect. In this lesson we record the real messages from real runs, so you can see that there is no magic.

All code was run with the MCP Python SDK 2.3.0. Its newest protocol version is 2026-07-28. Where older apps behave differently, we show both.

Worked example: An "office-helper" server with one tool, one resource and one prompt - used over stdio and over HTTP with the same client code.

workflowInside a host: one client per serverstep 1 / 4

1 - The host connects its clients

For each server it is configured to use, the host creates one client. Client 1 starts time-server as a child process; client 2 connects to office-helper over HTTP.

servers
2
clients
2 (one each)
transport 1
stdio
transport 2
Streamable HTTP

A host app with two MCP clients. One server runs as a child process (stdio); the other is reached over HTTP. The model never talks to a server directly.

Words you will see in this lesson

Here are the new words. Each one is explained again, with an example, below.

Small dictionary
ToolAn action the model can ask to run, such as get_current_time.
ResourceData the app can read, found by an address (a URI), such as office://holidays/2026.
PromptA ready-made message template the user can choose, such as "plan a meeting about...".
JSON-RPC 2.0A simple, widely used format for requests and responses in JSON. MCP uses it.
Request / responseA message that needs an answer (it has an id), and the answer (with the same id).
NotificationA message that needs no answer. It has no id.
TransportHow the messages travel: stdio (a child process) or HTTP.

An everyday example: a restaurant

You (the user) sit in a restaurant (the host). A waiter (a client) takes your order to the kitchen (a server). If the restaurant also has a bar, a second waiter or the same process goes to the bar - one connection per kitchen or bar.

The kitchen offers three kinds of things. Dishes it cooks when asked (tools - an action). A printed menu and a list of today’s allergens you can just read (resources - data). And set menus, like "the lunch special", that you choose as a whole (prompts - a ready-made template).

You never walk into the kitchen. You tell the waiter, the waiter talks to the kitchen in the kitchen’s own short codes, and brings back the result. In MCP, those short codes are JSON messages.

The three things a server offers

Tools are actions, and the model decides when to use them - for example, get the time, search, send a message. Resources are data with an address (a URI). The app decides when to read them - for example, to add a file to the conversation. Prompts are templates with a name and arguments. The user picks them - for example, from a menu in a chat app.

Below is one server with one of each. Notice that all three are normal Python functions. The decorator says what kind of thing each one is.

Example 1 - office_server.py: one tool, one resource, one prompt
import sys from datetime import datetime from zoneinfo import ZoneInfo from mcp.server.mcpserver import MCPServer server = MCPServer("office-helper") # 1. A TOOL: an action the model can ask to run. @server.tool() def get_current_time(timezone: str = "UTC") -> str: """Return the current date and time in an IANA timezone, such as Asia/Kolkata.""" return datetime.now(ZoneInfo(timezone)).strftime("%Y-%m-%d %H:%M:%S %Z") # 2. A RESOURCE: data the app can read, found by its URI (an address). @server.resource("office://holidays/2026", mime_type="text/plain") def holidays() -> str: """Office holidays in 2026.""" return "2026-01-26 Republic Day\n2026-08-15 Independence Day\n2026-10-02 Gandhi Jayanti" # 3. A PROMPT: a ready-made message template the user can pick. @server.prompt() def plan_meeting(topic: str) -> str: """Ask the model to plan a short meeting.""" return f"Plan a 30-minute meeting about {topic}. Avoid office holidays. Give an agenda with 3 points." if __name__ == "__main__": if len(sys.argv) > 1 and sys.argv[1] == "http": server.run("streamable-http", port=8931) # a web address: http://127.0.0.1:8931/mcp else: server.run() # stdio: the client starts this file itself
Example 2 - office_client.py: list everything, then use one of each
import asyncio, sys from mcp import Client, StdioServerParameters async def main(target): async with Client(target) as client: # connect (and disconnect at the end) tools = await client.list_tools() resources = await client.list_resources() prompts = await client.list_prompts() print("tools: ", [t.name for t in tools.tools]) print("resources:", [(str(r.uri), r.name) for r in resources.resources]) print("prompts: ", [(p.name, [a.name for a in (p.arguments or [])]) for p in prompts.prompts]) page = await client.read_resource("office://holidays/2026") print("read resource ->", page.contents[0].text.splitlines()[0], "...") prompt = await client.get_prompt("plan_meeting", {"topic": "the Q4 release"}) print("get prompt ->", prompt.messages[0].role, "|", prompt.messages[0].content.text[:60]) result = await client.call_tool("get_current_time", {"timezone": "Europe/Paris"}) print("call tool ->", result.content[0].text) if sys.argv[1] == "stdio": asyncio.run(main(StdioServerParameters(command=sys.executable, args=["office_server.py"]))) else: asyncio.run(main("http://127.0.0.1:8931/mcp"))
Output - python office_client.py stdio
tools: ['get_current_time'] resources: [('office://holidays/2026', 'holidays')] prompts: [('plan_meeting', ['topic'])] read resource -> 2026-01-26 Republic Day ... get prompt -> user | Plan a 30-minute meeting about the Q4 release. Avoid office call tool -> 2026-10-09 15:31:09 CEST
Tools, resources, prompts
ToolAn action. The MODEL decides to call it. Messages: tools/list, tools/call.
ResourceData with a URI. The APP decides to read it. Messages: resources/list, resources/read.
PromptA template. The USER picks it. Messages: prompts/list, prompts/get.

Tip: The prompt did not call the model. get_prompt only returns the filled-in message ("Plan a 30-minute meeting about the Q4 release..."). The host then sends it to the model - if and when the user wants.

The messages on the wire

MCP messages use JSON-RPC 2.0, a simple format that has existed since 2010. A request has "jsonrpc": "2.0", an id, a method (what to do) and params. The response has the same id and either a result or an error. A notification is a request without an id: no answer is expected.

To see the real messages, we put a small "spy" program between the client and time_server.py. It passes every line through, and copies it to a log. Here is what our Python client sent, and what came back. -> means client to server; <- means server to client.

The spy - copies every line to wire.log
# Sits between a client and the real server, and copies every line to wire.log. import subprocess, sys, threading log = open("wire.log", "a") proc = subprocess.Popen([sys.executable, *sys.argv[1:]], stdin=subprocess.PIPE, stdout=subprocess.PIPE) def pump(src, dst, tag): for line in iter(src.readline, b""): log.write(f"{tag} {line.decode().rstrip()}\n"); log.flush() dst.write(line); dst.flush() threading.Thread(target=pump, args=(proc.stdout, sys.stdout.buffer, "<-"), daemon=True).start() pump(sys.stdin.buffer, proc.stdin, "->") # The client starts the spy instead of the server: # StdioServerParameters(command=sys.executable, args=["spy.py", "time_server.py"])
wire.log - SDK 2.3.0, protocol 2026-07-28 (shortened only where marked ...)
-> {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"mcp","version":"0.1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} <- {"jsonrpc":"2.0","id":1,"result":{"cacheScope":"private","capabilities":{"prompts":{"listChanged":true},"resources":{"listChanged":true,"subscribe":true},"tools":{"listChanged":true}},"resultType":"complete","supportedVersions":["2026-07-28"],"ttlMs":0,"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"time-server","version":""}}}} -> {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"_meta":{...same as above...}}} <- {"jsonrpc":"2.0","id":2,"result":{...,"tools":[{"description":"Return the current date and time in an IANA timezone, such as Asia/Kolkata.","inputSchema":{"type":"object","properties":{"timezone":{"default":"UTC","title":"Timezone","type":"string"}},...},"name":"get_current_time","outputSchema":{...}}],...}} -> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_current_time","arguments":{"timezone":"Asia/Kolkata"},"_meta":{...}}} <- {"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"2026-10-09 18:59:49 IST","type":"text"}],"isError":false,"resultType":"complete","structuredContent":{"result":"2026-10-09 18:59:49 IST"},...}}

Two ways to start talking

Look at the first message above: server/discover. That is the newest way (protocol 2026-07-28). The client asks what the server supports, and then every request carries the protocol version and client details in its own "_meta" field. Each request stands alone. This makes servers easier to run behind load balancers, because no request depends on an earlier one.

Older apps - and every protocol version before 2026-07-28 - start with a handshake instead. The client sends initialize with the protocol version it wants. The server answers with the version it agrees to and what it can do. The client then sends a notification, notifications/initialized, and only after that the real requests.

Our 2.3.0 server understood both. We wrote the old handshake by hand, line by line, and it worked. But a plain tools/list with neither a handshake nor _meta was refused with error -32602, "Invalid request parameters". The good news: the SDK does all of this for you. You only need to know it exists - for example when an old app cannot connect to a new server.

Example 3 - by_hand.py: MCP without any SDK
import json, subprocess, sys server = subprocess.Popen([sys.executable, "time_server.py"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) def send(message, expect_answer=True): server.stdin.write(json.dumps(message) + "\n"); server.stdin.flush() # one JSON message per line print("->", json.dumps(message)[:150]) if expect_answer: answer = json.loads(server.stdout.readline()) print("<-", json.dumps(answer)[:230]) return answer # The handshake: initialize, then the "initialized" notification (no id, no answer) send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "by-hand", "version": "1.0"}}}) send({"jsonrpc": "2.0", "method": "notifications/initialized"}, expect_answer=False) send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) send({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "get_current_time", "arguments": {"timezone": "Asia/Kolkata"}}})
Output
-> {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "by-hand", <- {"jsonrpc": "2.0", "id": 1, "result": {"capabilities": {"prompts": {"listChanged": false}, "resources": {"listChanged": false, "subscribe": false}, "tools": {"listChanged": false}}, "protocolVersion": "2025-11-25", "serverInfo": { -> {"jsonrpc": "2.0", "method": "notifications/initialized"} -> {"jsonrpc": "2.0", "id": 2, "method": "tools/list"} <- {"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"description": "Return the current date and time in an IANA timezone, such as Asia/Kolkata.", "inputSchema": {"properties": {"timezone": {"default": "UTC", "title": "Timezone", "ty -> {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "get_current_time", "arguments": {"timezone": "Asia/Kolkata"}}} <- {"jsonrpc": "2.0", "id": 3, "result": {"content": [{"text": "2026-10-09 19:00:06 IST", "type": "text"}], "isError": false, "structuredContent": {"result": "2026-10-09 19:00:06 IST"}}} Without a handshake and without _meta: -> {"jsonrpc": "2.0", "id": 1, "method": "tools/list"} <- {"jsonrpc": "2.0", "id": 1, "error": {"code": -32602, "message": "Invalid request parameters", "data": ""}}

Transports: stdio or HTTP

A transport is how the messages travel. MCP has two.

stdio: the client starts the server as a child process and talks through its standard input and output - one JSON message per line. This is the usual choice for a server on your own computer. It needs no port and no network. One important rule follows: in a stdio server, stdout belongs to MCP. Anything else printed there breaks the message stream (Lesson 4.3 shows this happening).

Streamable HTTP: the server runs as a web service, and every message is an HTTP POST to one address, here http://127.0.0.1:8931/mcp. Use it when the server runs somewhere else - another machine, a container, the cloud - or when many users share it. Our client ran unchanged against the HTTP server; only the target changed. The server log showed 7 POST requests: one server/discover, three lists, one resource read, one prompt and one tool call.

Output - the same client, against the HTTP server
$ python office_server.py http # in one terminal $ python office_client.py http # in another tools: ['get_current_time'] resources: [('office://holidays/2026', 'holidays')] prompts: [('plan_meeting', ['topic'])] read resource -> 2026-01-26 Republic Day ... get prompt -> user | Plan a 30-minute meeting about the Q4 release. Avoid office call tool -> 2026-10-09 15:31:12 CEST server log: INFO: 127.0.0.1:52493 - "POST /mcp HTTP/1.1" 200 OK (7 times)
Which transport?
stdioServer on the same computer, started by the client. Simple, no network. One client per process.
Streamable HTTPServer somewhere else, or shared by many. Needs a port, and in real use, authentication.

Watch out: A server on HTTP can be reached by anything that can reach its port. Our demo listens on 127.0.0.1 (this computer only) and has no login. A real HTTP server needs authentication; MCP defines how to do it with OAuth.

Two kinds of error

Errors in MCP come at two levels, and it helps to tell them apart.

A protocol error means the request itself was wrong: an unknown method, or bad parameters. It comes back as a JSON-RPC "error" with a number. We sent the method "tools/fly" and got -32601, "Method not found".

A tool error means the request was fine, but the tool failed. It comes back as a normal "result" with "isError": true and a text explaining the problem. Asking for the tool "get_weather" (which does not exist) and asking for the timezone "Mars/Olympus" both came back this way. Why the difference? A tool error is meant for the MODEL: the host passes it on, and the model can read it and try again - just like the error messages your tools returned in Module 1.

Output - three mistakes, sent by hand after the handshake
-> {"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "get_current_time", "arguments": {"timezone": "Mars/Olympus"}}} <- {"jsonrpc": "2.0", "id": 4, "result": {"content": [{"text": "Error executing tool get_current_time", "type": "text"}], "isError": true}} -> {"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "get_weather", "arguments": {}}} <- {"jsonrpc": "2.0", "id": 5, "result": {"content": [{"text": "Unknown tool: get_weather", "type": "text"}], "isError": true}} -> {"jsonrpc": "2.0", "id": 6, "method": "tools/fly"} <- {"jsonrpc": "2.0", "id": 6, "error": {"code": -32601, "message": "Method not found", "data": "tools/fly"}}

Tip: "Error executing tool get_current_time" does not tell the model what was wrong with Mars/Olympus. Lesson 4.3 shows how to send a clear message the model can act on.

The server can ask too

Most messages go from client to server. But MCP also lets a server ask the client for things - only if the client says it supports them. The three you will hear about are: sampling (the server asks the host’s model to write something), roots (the server asks which folders it may work in), and elicitation (the server asks the user a question, such as "which account?").

You will not need these for a long time. Just remember: the client announces what it supports, and a well-behaved server only uses what was announced. In our wire log the client sent "clientCapabilities": {} - it offered nothing extra.

MCP architecture at a glance

Roles

Host (the app) - one client per server - server.

Tools

Actions the model chooses.

tools/list, tools/call
Resources

Data by URI the app reads.

resources/list, resources/read
Prompts

Templates the user picks.

prompts/list, prompts/get
Start (newest)

Discover, then _meta on every request.

server/discover
Start (older)

Handshake before any request.

initialize -> notifications/initialized
stdio

Child process, one JSON message per line.

server.run()
Streamable HTTP

POST to one URL.

server.run("streamable-http", port=8931)
Protocol error

Bad request - JSON-RPC error.

{"error": {"code": -32601, ...}}
Tool error

Tool failed - normal result.

{"result": {"isError": true, ...}}

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

Watch the wire

“Run the spy with office_server.py and Example 2. Find resources/read and prompts/get in wire.log.”

Add a resource

“Add a resource office://rooms that returns a list of meeting rooms. List and read it from the client.”

By hand

“In Example 3, add a resources/list request. What is the method name, and what comes back?”

Break it

“Send a tools/call with "argumnts" (spelled wrong). Is it a protocol error or a tool error?”

Switch transport

“Run office_server.py http and point the client at it. What changed in your client code?”

What usually goes wrong

Putting data behind a tool when it should be a resource

A tool is for the model to act. Fixed data the app should show or attach - a document, a list of holidays - fits better as a resource with a URI.

Treating a tool error as a crash

isError: true is a normal answer. Pass it to the model so it can try again with better arguments, as in Module 1.

Expecting a prompt to call the model

prompts/get only returns the filled-in messages. The host decides whether to send them to the model.

Exposing an HTTP server without a login

Anything that can reach the port can call your tools. Listen on 127.0.0.1 while learning; add authentication before sharing.

Key points

  • A host contains one MCP client per server; the model never talks to a server directly.
  • A server offers tools (model chooses), resources (app reads by URI) and prompts (user picks).
  • Messages are JSON-RPC 2.0: requests and responses share an id; notifications have none.
  • Protocol 2026-07-28 starts with server/discover and puts _meta on every request; older versions use the initialize handshake.
  • stdio runs the server as a child process - stdout belongs to MCP; Streamable HTTP is a POST to /mcp.
  • Protocol errors are JSON-RPC errors; tool errors are results with isError: true, meant for the model.

Quick check before you move on

Who decides when a tool, a resource and a prompt are used?
Tool: the model. Resource: the app. Prompt: the user.
What is the difference between a request and a notification?
A request has an id and gets a response with the same id. A notification has no id and gets no answer.
When would you choose Streamable HTTP over stdio?
When the server runs on another machine or in the cloud, or is shared by many users.
Calling an unknown tool - protocol error or tool error?
A tool error: a normal result with isError: true ("Unknown tool: get_weather").

Quiz

  1. 1.

    What is the first message the 2.3.0 Python client sent?

  2. 2.

    In the older handshake, which message has no id, and why?

  3. 3.

    What came back for the method "tools/fly"?

  4. 4.

    How many POST requests did Example 2 make over HTTP, and for what?

Interview questions

Describe MCP’s architecture.

A host application embeds one MCP client per server connection. Servers expose tools (model-invoked actions), resources (URI-addressed data the application reads) and prompts (user-selected templates). Messages are JSON-RPC 2.0 over stdio or Streamable HTTP. Clients may also offer capabilities to servers - sampling, roots, elicitation - which servers use only if announced.

stdio or Streamable HTTP - what are the trade-offs?

stdio is simple and local: the host spawns the server, there is no network surface, but it is one process per client on one machine. Streamable HTTP supports remote and shared servers and scaling, but needs authentication, network security and deployment.

Why separate tool errors from protocol errors?

Protocol errors are for the client - the request was malformed. Tool errors are content for the model: returned as a successful response with isError so the host can show them to the model, which can correct its arguments and retry.

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...