MCP 201

 

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.


No comments:

Post a Comment