Chapter 8: Client/Server Sessions (ClientSession
, ServerSession
)
Welcome back! In Chapter 7: MCP Protocol Types, we learned about the standardized “digital forms” – the Pydantic models – that define the structure of messages exchanged between an MCP client and server. We saw examples like CallToolRequest
and ProgressNotification
.
But knowing the format of a letter isn’t enough. How does a specific conversation between one client and one server actually happen over time? How does the server know which incoming response belongs to which outgoing request it sent earlier? How is the initial connection “hello” handled?
Imagine you call a large company’s support line. You don’t just shout into the void; you get connected to a specific operator who handles your call from start to finish. This operator keeps track of your requests, finds the answers, and manages the connection until you hang up.
In the MCP Python SDK
, this “phone line operator” role is played by Session objects: ClientSession
and ServerSession
.
What’s a Session? The Dedicated Conversation Line
A Session object (ClientSession
or ServerSession
) manages the state and lifecycle of a single, ongoing connection between one MCP client and one MCP server. Think of it as establishing a dedicated phone line for one specific conversation.
This “operator” handles several crucial tasks for that single connection:
- Initialization: Manages the initial “handshake” where the client and server introduce themselves, agree on the protocol version, and share their capabilities (like saying “Hello, I can do X, Y, and Z”).
- Sending & Receiving: Handles the low-level details of sending outgoing messages (requests, notifications) and receiving incoming messages over the communication channel (like Stdio, WebSockets, etc., which we’ll cover in Chapter 9: Communication Transports).
- Request/Response Matching: When you send a request, it gets a unique ID. When a response comes back later with that same ID, the Session makes sure it’s delivered to the part of the code that’s waiting for that specific answer. It’s like the operator remembering who asked which question.
- State Management: Keeps track of whether the connection is initializing, active, or closed.
- Lifecycle: Manages the setup and eventual teardown (hang-up) of the connection.
Two Sides of the Coin: ClientSession
vs. ServerSession
Why are there two types of sessions? Because the client and server have different roles in the conversation:
ClientSession
: Represents the client’s end of the connection. It’s primarily responsible for:- Initiating the connection and the handshake (
initialize
request). - Sending requests to the server (like
callTool
,readResource
,getPrompt
). - Receiving responses and notifications from the server.
- Handling server-initiated requests (like asking the client to generate text if the client has that capability).
- Initiating the connection and the handshake (
ServerSession
: Represents the server’s end of the connection. It’s primarily responsible for:- Responding to the client’s
initialize
request. - Receiving requests from the client.
- Sending responses and notifications back to the client (like tool results, resource content, log messages, progress updates).
- Handling client-initiated notifications (like
initialized
).
- Responding to the client’s
They use the same underlying mechanisms but have different methods tailored to their role (e.g., ClientSession
has call_tool
, ServerSession
has send_log_message
).
How FastMCP
Uses ServerSession
(Behind the Scenes)
If you’re building a server using FastMCP
(as we did in chapters 2 through 6), you generally don’t interact with ServerSession
directly.
When a client connects to your FastMCP
server:
- The underlying transport layer (e.g., Stdio handler) accepts the connection.
FastMCP
(or its underlyingMCPServer
) automatically creates aServerSession
object specifically for that new client connection.- This
ServerSession
handles the initialization handshake with the client. - When the client sends a request (like
callTool
), theServerSession
receives it, identifies it, and passes it to the appropriateFastMCP
handler (which might involve theToolManager
). - When your tool function uses
ctx.info()
orctx.report_progress()
(Chapter 6: FastMCP Context (Context
)), theContext
object talks to its associatedServerSession
to actually send theLoggingMessageNotification
orProgressNotification
back to the client. - The
ServerSession
manages this connection until the client disconnects.
So, ServerSession
is the hidden engine powering the communication for each connected client in a FastMCP
server. You benefit from its work without needing to manage it manually.
When Might You Use ClientSession
?
You would typically use ClientSession
if you were writing a standalone Python application that needs to connect to and interact with an existing MCP server (which might be one you built with FastMCP
or someone else’s).
Example Scenario: A Simple Client
(This is conceptual; we won’t build a full client here.)
Imagine you write a script that needs to ask our CalculatorServer
(Chapter 4) to add two numbers.
# --- Conceptual Client Code ---
import anyio
from mcp.client.session import ClientSession
# Assume we have transport streams (read_stream, write_stream)
# connected to the CalculatorServer (more in Chapter 9)
async def run_client():
# 1. Create a ClientSession using the transport streams
async with ClientSession(read_stream, write_stream) as session:
try:
# 2. Perform the initialization handshake
init_result = await session.initialize()
print(f"Connected to: {init_result.serverInfo.name}")
# 3. Send a 'callTool' request using the session
tool_result = await session.call_tool(
name="add",
arguments={"num1": 15, "num2": 27}
)
# 4. Process the result (session handled matching response)
# Assuming the result is simple text content
if tool_result.content and tool_result.content[0].type == 'text':
print(f"Server calculated: {tool_result.content[0].text}") # Expected: 42
except Exception as e:
print(f"An error occurred: {e}")
# In a real script, you'd set up the transport and run this async function
# anyio.run(run_client)
In this scenario:
- We create the
ClientSession
. - We explicitly call
session.initialize()
to start the conversation. - We use
session.call_tool()
to send the request. TheClientSession
assigns an ID, sends the message, and waits for the specific response with that ID. - The result comes back directly from the
call_tool
method.
How Sessions Work Under the Hood: The Operator’s Workflow
Let’s trace the lifecycle and the request/response matching managed by a session. We’ll use our phone operator analogy.
- Connection Established: A communication channel (like Stdio or WebSocket, see Chapter 9) is opened between the client and server.
- Session Creation: A
ClientSession
is created on the client side, and aServerSession
on the server side, both linked to this channel. - Initialization (Handshake):
ClientSession
sends anInitializeRequest
(like calling and saying “Hi, I’m ClientApp v1.0, I support MCP v0.3, can we talk?”). It assigns this request ID 0.ServerSession
receives ID 0. It knows this is theinitialize
method. It checks the protocol version, stores the client’s capabilities, and prepares its own info.ServerSession
sends back anInitializeResult
linked to ID 0 (like “Yes, I’m CalculatorServer v1.1, I also support v0.3, here are my capabilities…”).ClientSession
receives the response for ID 0. It checks the server’s info and considers the handshake successful.ClientSession
sends anInitializedNotification
(just saying “Okay, great!”).ServerSession
receives this notification and marks the session as fully initialized. The line is now open for regular business.
- Client Sends Request:
ClientSession
wants to call theadd
tool. It callssession.call_tool("add", {...})
.- The
ClientSession
assigns a new unique ID (e.g., ID 1) to this request. - It stores a “waiting placeholder” (an
anyio
event or future) associated with ID 1. - It sends the
CallToolRequest
message with ID 1 over the channel.
- Server Processes Request:
ServerSession
receives the message with ID 1.- It sees it’s a
callTool
request foradd
. - It passes the request details to the
FastMCP
handler (which uses theToolManager
). - The tool function
add_numbers(15, 27)
runs and returns42
. FastMCP
gets the result.
- Server Sends Response:
ServerSession
constructs aCallToolResult
containing42
.- It sends this result back over the channel, making sure to include the original request ID (ID 1).
- Client Receives Response:
ClientSession
receives the message with ID 1.- It looks up ID 1 in its “waiting placeholders”.
- It finds the placeholder created in step 4 and delivers the received
CallToolResult
to it. - The code that was waiting on
session.call_tool(...)
now receives the result (42
) and continues execution.
- Notifications (Example: Progress):
- If the server tool called
ctx.report_progress(...)
, theContext
tells theServerSession
. ServerSession
constructs aProgressNotification
(which doesn’t have a request ID, as it’s not a response).ServerSession
sends the notification.ClientSession
receives the notification. It sees it’s not a response to a specific request. It might trigger a callback or event handler registered in the client application to update a progress bar.
- If the server tool called
- Hang-up: When the connection closes (client exits, server shuts down, network error), the sessions clean up their resources.
Simplified Sequence Diagram (Client Calls Tool):
sequenceDiagram
participant ClientApp
participant ClientSess as ClientSession
participant ServerSess as ServerSession
participant ServerTool as Tool Function (e.g., add_numbers)
ClientApp->>+ClientSess: call_tool("add", {num1: 15, num2: 27})
ClientSess->>ClientSess: Assign Request ID (e.g., 1)
ClientSess->>ClientSess: Store 'waiter' for ID 1
ClientSess->>+ServerSess: Send CallToolRequest (ID=1, method="tools/call", params={...})
ServerSess->>ServerSess: Receive request ID=1
ServerSess->>+ServerTool: Dispatch request to tool handler
ServerTool-->>-ServerSess: Return result (e.g., 42)
ServerSess->>-ClientSess: Send CallToolResult (ID=1, result={content: [{"type": "text", "text": "42"}]})
ClientSess->>ClientSess: Receive response ID=1
ClientSess->>ClientSess: Match ID=1 to 'waiter'
ClientSess-->>-ClientApp: Return result (CallToolResult object)
This flow highlights how the session objects act as intermediaries, managing IDs and matching responses back to their original requests.
Diving into the Code (Briefly!)
You typically won’t call these methods directly when using FastMCP
for servers, but seeing the structure helps understand the session’s role. These snippets are heavily simplified.
Base Class (shared/session.py
):
Both ClientSession
and ServerSession
inherit from BaseSession
, which contains the core logic for sending/receiving and request/response matching.
# Simplified from shared/session.py
import anyio
from mcp.types import JSONRPCRequest, JSONRPCResponse, JSONRPCError, ErrorData
class BaseSession:
def __init__(self, read_stream, write_stream, ...):
self._read_stream = read_stream
self._write_stream = write_stream
self._response_streams = {} # Stores 'waiters' for responses, keyed by request ID
self._request_id_counter = 0
# ... other setup ...
async def send_request(self, request, result_type):
# 1. Get a new unique ID
request_id = self._request_id_counter
self._request_id_counter += 1
# 2. Create a 'waiter' (memory stream) to receive the response
response_receiver, response_sender = anyio.create_memory_object_stream(1)
self._response_streams[request_id] = response_sender
# 3. Format the request with the ID
jsonrpc_request = JSONRPCRequest(id=request_id, **request.model_dump())
# 4. Send it over the write stream
await self._write_stream.send(JSONRPCMessage(jsonrpc_request))
# 5. Wait for the response to arrive on the 'waiter' stream
response_or_error = await response_receiver.receive() # Timeout logic omitted
# 6. Process response/error and return result
if isinstance(response_or_error, JSONRPCError):
raise McpError(response_or_error.error)
else:
return result_type.model_validate(response_or_error.result)
async def _receive_loop(self):
# Runs in the background, reading from the read_stream
async for message in self._read_stream:
if isinstance(message.root, (JSONRPCResponse, JSONRPCError)):
# It's a response or error for a request we sent
request_id = message.root.id
# Find the matching 'waiter' stream
response_sender = self._response_streams.pop(request_id, None)
if response_sender:
# Send the response back to the waiting send_request call
await response_sender.send(message.root)
else:
print(f"Warning: Received response for unknown request ID {request_id}")
elif isinstance(message.root, JSONRPCRequest):
# It's a new request *from* the other side
# Subclasses (Client/ServerSession) handle this differently
await self._handle_incoming_request(message.root)
elif isinstance(message.root, JSONRPCNotification):
# It's a notification *from* the other side
await self._handle_incoming_notification(message.root)
This shows the core send_request
logic (assign ID, store waiter, send, wait) and the _receive_loop
logic (read message, if response -> find waiter, if request/notification -> handle).
Server Session (server/session.py
):
Adds server-specific logic, like handling the initialize
request and sending server-to-client notifications.
# Simplified from server/session.py
from mcp.types import InitializeRequest, InitializeResult, InitializedNotification
class ServerSession(BaseSession):
# ... (init with server info, capabilities) ...
_initialization_state = InitializationState.NotInitialized
_client_params = None # Stores client info after initialization
async def _handle_incoming_request(self, request: JSONRPCRequest):
# Server specifically handles 'initialize' request first
if request.method == "initialize":
# ... (validate request, store client capabilities in self._client_params) ...
self._initialization_state = InitializationState.Initializing
init_result = InitializeResult(...) # Build result with server info
# Respond directly using the base class's internal send method
await self._send_response(request.id, ServerResult(init_result))
elif self._initialization_state == InitializationState.Initialized:
# For other requests, pass them to the main server logic
# (e.g., to FastMCP's request router) via an internal queue
await self._pass_request_to_server_handler(request)
else:
# Error: Request received before initialization complete
error = ErrorData(code=..., message="Server not initialized")
await self._send_response(request.id, error)
async def _handle_incoming_notification(self, notification: JSONRPCNotification):
if notification.method == "initialized":
self._initialization_state = InitializationState.Initialized
print("ServerSession: Client initialization complete.")
elif self._initialization_state == InitializationState.Initialized:
# Pass other notifications to server logic if needed
pass
else:
# Ignore notifications before initialized, or log warning
pass
async def send_log_message(self, level, data, logger=None):
# Helper method to send a specific notification type
log_notification = LoggingMessageNotification(...)
await self.send_notification(ServerNotification(log_notification))
# ... other methods like send_progress_notification, send_resource_updated ...
This highlights how ServerSession
intercepts the initialize
request and the initialized
notification to manage the connection state before passing other messages to the main server logic.
Conclusion
You’ve now explored ClientSession
and ServerSession
, the dedicated operators managing individual communication lines between MCP clients and servers.
- A Session handles the lifecycle of a single connection.
- It manages the initialization handshake.
- It reliably sends and receives messages (requests, responses, notifications).
- Crucially, it matches incoming responses to outgoing requests using unique IDs.
ClientSession
is used by clients to initiate connections and send requests to servers.ServerSession
is used by servers to handle connections and respond to clients.- Frameworks like
FastMCP
manageServerSession
automatically for you; interaction often happens indirectly via theContext
object.
Sessions provide the robust foundation for the request-response patterns and asynchronous notifications that make MCP communication work.
In the final chapter of this foundational series, we’ll look at the different ways these sessions can actually transmit their messages back and forth: the various Chapter 9: Communication Transports (Stdio, SSE, WebSocket, Memory).
Generated by AI Codebase Knowledge Builder