Lesson 6 / 31
The Initialize Handshake and Capabilities
See how client and server agree on protocol version and features.
Say hello, agree, then work
Every MCP session starts with an initialize request from the client carrying its protocolVersion, its capabilities (for example whether it supports sampling or roots) and clientInfo. The server answers with the version it will use, its own capabilities (tools, resources, prompts, with flags such as listChanged) and serverInfo. The client then sends a notifications/initialized notification and normal traffic begins. Each side may use only the features both declared; this is capability negotiation, and it is how protocols stay backward compatible as new features appear. Later the session ends by closing the transport.
A server to talk to
This small server (built with the official Python SDK, version 2.x) exposes one tool, one resource and one prompt. Save it as server.py. The next examples drive it over raw JSON-RPC.
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("demo-orders")
@mcp.tool()
def get_order_status(order_id: str) -> dict:
"""Return the status of ONE order. order_id is exactly 6 digits."""
if not (order_id.isdigit() and len(order_id) == 6):
return {"error": "order_id must be exactly 6 digits"}
return {"order_id": order_id, "status": "shipped"}
@mcp.resource("policy://refunds")
def refund_policy() -> str:
"""The refund policy text."""
return "Refunds above 5000 rupees need approval."
@mcp.prompt()
def triage(ticket: str) -> str:
"""A prompt template for ticket triage."""
return f"Classify this ticket as billing, technical or other: {ticket}"
if __name__ == "__main__":
mcp.run("stdio")
Driving the server by hand, run
I ran this against the real server shown earlier, using the official mcp Python SDK 2.2.0 in a virtual environment; the client side is raw JSON-RPC over stdio with no SDK. Save the server as server.py in the same folder. SDK names and defaults change between versions, so check the current documentation. The client sends initialize, then the initialized notification, then lists tools, calls one with valid, invalid and missing arguments, tries an unknown method, and reads a resource and a prompt. The server reports protocol 2025-06-18 and the capabilities prompts, resources, tools (plus an experimental entry).
import json, subprocess, sys
proc = subprocess.Popen([sys.executable, "server.py"], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
def send(msg):
proc.stdin.write(json.dumps(msg) + "\n"); proc.stdin.flush()
def call(id_, method, params=None):
send({"jsonrpc": "2.0", "id": id_, "method": method, **({"params": params} if params is not None else {})})
return json.loads(proc.stdout.readline())
init = call(1, "initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "demo-client", "version": "0.1"}})
print("server:", init["result"]["serverInfo"]["name"], "| protocol:", init["result"]["protocolVersion"])
print("capabilities:", sorted(init["result"]["capabilities"]))
send({"jsonrpc": "2.0", "method": "notifications/initialized"}) # a notification: no id, no reply
tools = call(2, "tools/list")["result"]["tools"]
print("tools:", [t["name"] for t in tools])
print("input schema:", json.dumps(tools[0]["inputSchema"]))
ok = call(3, "tools/call", {"name": "get_order_status", "arguments": {"order_id": "481516"}})
print("call ok :", ok["result"]["content"][0]["text"], "| isError:", ok["result"].get("isError"))
bad = call(4, "tools/call", {"name": "get_order_status", "arguments": {"order_id": "12"}})
print("call bad id :", bad["result"]["content"][0]["text"])
missing = call(5, "tools/call", {"name": "get_order_status", "arguments": {}})
print("missing arg :", "isError" , missing["result"].get("isError"), "|", missing["result"]["content"][0]["text"][:60].replace(chr(10), " "))
unknown = call(6, "no/such/method")
print("unknown :", unknown["error"]["code"], unknown["error"]["message"])
res = call(7, "resources/list")["result"]["resources"]
print("resources:", [r["uri"] for r in res])
print("read:", call(8, "resources/read", {"uri": "policy://refunds"})["result"]["contents"][0]["text"])
pr = call(9, "prompts/get", {"name": "triage", "arguments": {"ticket": "I was charged twice"}})
print("prompt:", pr["result"]["messages"][0]["content"]["text"])
proc.stdin.close(); proc.wait()
Output:
server: demo-orders | protocol: 2025-06-18
capabilities: ['experimental', 'prompts', 'resources', 'tools']
tools: ['get_order_status']
input schema: {"properties": {"order_id": {"title": "Order Id", "type": "string"}}, "required": ["order_id"], "type": "object", "title": "get_order_statusArguments"}
call ok : {
"order_id": "481516",
"status": "shipped"
} | isError: False
call bad id : {
"error": "order_id must be exactly 6 digits"
}
missing arg : isError True | Error executing tool get_order_status: 1 validation error fo
unknown : -32601 Method not found
resources: ['policy://refunds']
read: Refunds above 5000 rupees need approval.
prompt: Classify this ticket as billing, technical or other: I was charged twiceCapability negotiation, run
I ran this with plain Python 3 (standard library only). Only features both sides declared are usable: with no client capabilities just tools and resources work; sampling needs the client to declare it and the server to want it; asking for roots from a client that never declared it is ignored.
def negotiate(client_caps, server_caps):
"""Use only features that BOTH sides declared during initialize."""
usable = []
if "tools" in server_caps:
usable.append("tools")
if "resources" in server_caps:
usable.append("resources")
if "sampling" in client_caps and server_caps.get("wants_sampling"):
usable.append("sampling (server asks the client's model)")
if "roots" in client_caps and server_caps.get("wants_roots"):
usable.append("roots")
return usable
print(negotiate({}, {"tools": {}, "resources": {}}))
print(negotiate({"sampling": {}}, {"tools": {}, "wants_sampling": True}))
print(negotiate({"sampling": {}}, {"tools": {}, "wants_roots": True}))
Output:
['tools', 'resources'] ['tools', "sampling (server asks the client's model)"] ['tools']
Quick check: What happens right after the server answers initialize?
- Nothing can happen again
- The session is deleted
- The model is retrained
- The client sends notifications/initialized and normal traffic begins
Answer
The client sends notifications/initialized and normal traffic begins — The initialized notification completes the handshake.