MCP 201: The Handshake and FastMCP (Under the Hood)
Post 01 outlined the overall architecture. It detailed interacting layers, communication flows, and six core primitives. However, that model provides limited help for actual implementation.
This follow-up focuses on the underlying mechanics. First, it examines the initial client-server connection exchange. Then, it demonstrates how the FastMCP Python framework automatically exposes standard functions. FastMCP does this through the protocol without manual JSON-RPC messages.
In Scope:
Provides a step-by-step audit of the initialize request-response pair required for session parity.
Explains how decorators like @mcp.tool and @mcp.resource use introspection to register functions as protocol primitives at import time.
Analyzes client lifecycle management using ClientSession and AsyncExitStack to ensure deterministic transport cleanup.
Maps the conceptual nine-hop communication flow from Post 01 to actual underlying SDK method calls.
Out of Scope:
Excludes authentication, authorization, and message-level security mechanics from this technical deep dive.
Omits details on the 2026-07-28 revision that deprecated the handshake and transitioned FastMCP to MCPServer.
Note on the 2026-08-16 Correction:
Code examples utilize the legacy decorator-centric API (from mcp.server.fastmcp import FastMCP) prevalent in earlier SDK versions.
SDK version 2.0.0 removes the mcp.server.fastmcp module, breaking backward compatibility and triggering ModuleNotFoundError.
Preserving server-side functionality for the provided examples requires pinning dependencies to mcp<2.0.0.
Note that client-side lifecycle mechanics, specifically ClientSession and AsyncExitStack, remain consistent in version 2.0.0.
1. The Handshake: What "Connecting" Actually Means
A client cannot simply request a tool list out of thin air; two previously unacquainted entities (MCP Client and MCP Server) must first establish protocol parity and exchange their respective capabilities. This is the handshake: three distinct messages.
Every subsequent interaction (tools/list, resources/read, the works) remains strictly gated behind this initial parity check. If you bypass the handshake, the server simply remains silent.
The initialize handshake: three messages before anything else is allowed.
In code, this whole exchange collapses into one line inside connect():
async def connect(self):
server_params = StdioServerParameters(
command=self._command,
args=self._args,
env=self._env,
)
stdio_transport = await self._exit_stack.enter_async_context(
stdio_client(server_params)
)
_stdio, _write = stdio_transport
self._session = await self._exit_stack.enter_async_context(
ClientSession(_stdio, _write)
)
await self._session.initialize()
ClientSession initializes read/write streams without initiating network communication.
await self._session.initialize() sends the initialize request to the server.
The client receives InitializeResult containing server capabilities and info.
The client transmits the notifications/initialized acknowledgment to finalize session setup.
Testing and Debugging via MCP Inspector
Run uv run mcp dev mcp_server.py to launch the official MCP Inspector.
Access the web UI at localhost:6274 to connect directly to the running server.
The tool executes the initial handshake automatically upon connection.
Inspect raw JSON-RPC traffic while testing tools/list and resources/read endpoints.
2. FastMCP: A Function Becomes a Tool
Decorators inspect function signatures at import time.
JSON Schemas are automatically generated without manual protocol messages.
from pydantic import Field
@mcp.tool(
name="edit_document",
description="Edit a document by replacing a string in its contents.",
)
def edit_document(
doc_id: str = Field(description="Id of the document to edit"),
old_str: str = Field(description="Text to replace"),
new_str: str = Field(description="Replacement text"),
):
if doc_id not in docs:
raise ValueError(f"Doc with id {doc_id} not found")
docs[doc_id] = docs[doc_id].replace(old_str, new_str)
A decorated function becomes a registered tool at import time, nothing is sent over the wire yet.
Three things here are worth slowing down for:
Errors convert to result flags. ValueError exceptions become CallToolResult objects with isError=True, preventing process crashes.
No-ops return success. Unmatched strings in .replace() return without error, requiring explicit application-level verification.
Schemas lack runtime validation. FastMCP builds JSON schemas strictly from type hints and Field metadata without validating function behavior.
3. Resources: Static vs. Templated
Decorators register resources at import time, similar to tools.
Resources are addressed via URI rather than called by name.
URIs support parameter extraction for templated routing.
@mcp.resource(
"docs://documents",
mime_type="application/json",
)
def list_docs():
# Return a list of document names
return list(docs.keys())
@mcp.resource(
"docs://documents/{doc_id}",
mime_type="text/plain",
)
def fetch_doc(doc_id: str):
# Return the contents of a doc
if doc_id not in docs:
raise ValueError(f"Doc with id {doc_id} not found")
return docs[doc_id]
Static resources always route to one function; templated resources parse a segment out of the requested URI.
Static resources use a single, fixed URI registered in resources/list.
Templated resources extract URI placeholders as function keyword arguments.
FastMCP automates routing without manual logic or conditional blocks.
The mime_type serves as a caller hint rather than a strict structural contract.
4. The Client Side: One Job
Management of the connection lifecycle.
Ensuring deterministic cleanup of transport and session resources.
Thin proxying of tool and resource requests to the active session.
class MCPClient:
def __init__(self, command: str, args: list[str], env: Optional[dict] = None):
self._command = command
self._args = args
self._env = env
self._session: Optional[ClientSession] = None
self._exit_stack: AsyncExitStack = AsyncExitStack()
async def connect(self):
... # section 1
async def list_tools(self) -> list[types.Tool]:
result = await self.session.list_tools()
return result.tools
async def call_tool(self, tool_name: str, tool_input: dict):
return await self.session.call_tool(tool_name, tool_input)
async def cleanup(self):
await self._exit_stack.aclose()
async def __aenter__(self):
await self.connect()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.cleanup()
AsyncExitStack unwinds the stdio transport and ClientSession together, in reverse order, on exit.
Connection management via AsyncExitStack. Ensures deterministic cleanup of transport and session resources in reverse order.
Bracketed lifecycle design. The class provides a safe connect/cleanup wrapper around thin tool and resource proxies.
Exception-safe resource unwinding. Automatically closes the stdio subprocess and session whether the program exits cleanly or crashes.
5. One Round Trip: Underlying Function Calls
Tracing the protocol from diagrammatic boxes to actual SDK function calls.
Registration of tools at import time enables low-latency retrieval.
Conversion of server-side exceptions into isError=True flags for client-side safety.
list_tools() and call_tool() traced one layer down, into the actual session/server calls.
ListToolsResult retrieval from the @mcp.tool registry established at import time.
Server-side exception mapping to CallToolResult with isError=True.
Requirement for explicit client-side result checking instead of try/except blocks.
6. Transport and Implementation Context
Stdio transport mechanics. Standardizes local subprocess communication over pipes (stdin/stdout), leveraging asynchronous I/O to handle JSON-RPC message framing without network overhead.
Implicit security model. Provides private-by-construction transport by restricting communication to the parent-child process relationship, bypassing the need for complex authentication handshake layers in local contexts.
Stateful session lifecycle. The classic API relies on a sequential initialize handshake to negotiate protocol versions and capabilities, establishing a stateful connection for the duration of the process.
Protocol evolution in v2.0.0. Transitioned to a stateless protocol core that eliminates the handshake requirement, significantly reducing connection latency for short-lived execution environments.
Dependency management requirements. Maintaining compatibility with the decorator-based FastMCP implementation requires strict version pinning to avoid breaking changes introduced by the move to the MCPServer class structure.
What's Next
Migration to the stateless MCPServer API. Outlines the refactoring of decorator-based logic into the class-based structure required by SDK v2.0.0, ensuring compatibility with new transport interfaces.
Impact of handshake removal on protocol latency. Analyzes how the transition to a stateless core eliminates the triple-message initialize exchange, optimizing performance for ephemeral serverless and CLI-based execution.
Advanced security and authorization mechanics. Covers the implementation of scoped permissions, message-level signing, and identity verification necessary for moving beyond local stdio-based deployments to networked environments.