Building an Autonomous MCP Client in Python: Discovery, Sampling, and Roots

2026-09-18 · by bixzell

Most tutorials in the Model Context Protocol ecosystem focus on creating MCP Servers—wrapping internal APIs or databases into tool endpoints. While essential, that represents only half of the equation.

If you are developing custom autonomous agents, background cron workers, proprietary terminal CLIs, or internal multi-agent platforms, you must know how to build an MCP Client.

An MCP client is the orchestrator: it spawns and manages server processes, performs protocol handshakes, translates advertised tool schemas into LLM function-calling definitions, and dispatches tool execution. In this guide, we will build a complete, standalone MCP client in Python using the official mcp SDK.

Reference Implementation:modelcontextprotocol/servers—⭐

Official MCP server ecosystem that your custom Python client can interact with out of the box.


1. Prerequisites & Environment Setup

Install the official Python MCP library:

pip install mcp anthropic

The mcp package provides:

  • Protocol data models (mcp.types).
  • Client session management (mcp.ClientSession).
  • Stdio transport primitives (mcp.client.stdio.stdio_client, StdioServerParameters).

2. Spawning Servers and Establishing the Session

An MCP client connects to a server over a transport. For local servers, the standard mechanism is spawning the server as a child process and attaching to its standard I/O streams:

import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

# Define the server startup parameters
server_params = StdioServerParameters(
    command="python",
    args=["path/to/my_server.py"],
    env={"LOG_LEVEL": "INFO"}
)

async def main():
    # Launch server child process over stdio
    async with stdio_client(server_params) as (read_stream, write_stream):
        async with ClientSession(read_stream, write_stream) as session:
            # Step 1: Protocol Handshake
            await session.initialize()
            print("Connected to MCP server and negotiated capabilities.")
            
            # Step 2: Query Advertised Tools
            tools = await session.list_tools()
            print(f"Discovered {len(tools.tools)} available tools.")

asyncio.run(main())

The Initialization Handshake (initialize)

Calling await session.initialize() is mandatory. During this handshake:

  1. The client transmits its protocol version and supported capabilities (such as Roots and Sampling).
  2. The server responds with its advertised features (Tools, Resources, Prompts).
  3. Both sides agree on communication boundaries before any tool calls execute.

3. Translating MCP Tools to LLM Function Calling

Once connected, the client queries available tools via await session.list_tools(). Each tool object contains:

  • name: Unique identifier (e.g., calculate_metrics).
  • description: Human and LLM-readable summary of what the tool accomplishes.
  • inputSchema: A complete JSON Schema describing required properties and types.

To pass these tools to an LLM provider (such as Anthropic or OpenAI), map the MCP schema to the provider’s tool format:

def format_tools_for_llm(mcp_tools):
    return [
        {
            "name": tool.name,
            "description": tool.description,
            "input_schema": tool.inputSchema,
        }
        for tool in mcp_tools.tools
    ]

Because the MCP specification adopted standard JSON Schema for parameter validation, no complex schema re-engineering is needed—the schema maps directly to modern LLM function calling endpoints.


4. The Autonomous Execution Loop

With tools formatted, the client enters an autonomous reasoning and execution loop:

import json
from anthropic import Anthropic

client = Anthropic()

async def run_agent_loop(session, user_prompt: str):
    # 1. Discover tools dynamically
    tools_result = await session.list_tools()
    llm_tools = format_tools_for_llm(tools_result)

    messages = [{"role": "user", "content": user_prompt}]

    while True:
        # 2. Query model with current conversation and available tools
        response = client.messages.create(
            model="claude-3-7-sonnet-20250219",
            max_tokens=2048,
            tools=llm_tools,
            messages=messages,
        )

        # 3. Check if the model opted to call a tool
        tool_calls = [c for c in response.content if c.type == "tool_use"]
        
        if not tool_calls:
            # No further tools required; print final assistant response
            text_blocks = [c.text for c in response.content if c.type == "text"]
            return "\n".join(text_blocks)

        # 4. Append assistant message containing tool intent
        messages.append({"role": "assistant", "content": response.content})

        # 5. Dispatch each requested tool call to the MCP server
        for call in tool_calls:
            tool_name = call.name
            tool_args = call.input
            print(f"Executing tool: {tool_name}({tool_args})")

            # Execute tool through the active MCP session
            result = await session.call_tool(tool_name, tool_args)

            # Format tool result into context
            tool_result_content = "\n".join(
                item.text for item in result.content if item.type == "text"
            )

            messages.append({
                "role": "user",
                "content": [
                    {
                        "type": "tool_result",
                        "tool_use_id": call.id,
                        "content": tool_result_content,
                    }
                ],
            })

5. Advanced Client Capabilities: Roots and Sampling

While basic clients only invoke tools, the MCP specification defines three advanced client capabilities that elevate agent security and autonomy:

1. Roots (roots/list)

Certain tools (such as linters, search indices, and file organizers) need physical filesystem access. However, giving an untrusted server full disk traversal rights is hazardous.

The client can expose Roots to the server. Roots are explicit whitelisted directory paths:

# The client declares: "You may only access these directories"
roots = [
    {"uri": "file:///workspace/project-alpha", "name": "Project Root"},
    {"uri": "file:///workspace/docs", "name": "Documentation"},
]

The server checks these roots before attempting operations, enforcing strict boundary compliance.

2. Client-Side Sampling (sampling/createMessage)

In complex architectures, an MCP server might require LLM reasoning internally (for instance, summarizing a 100-page PDF before returning a tool result).

If the server managed its own LLM connections, it would require its own API keys, rate limit handlers, and model configurations.

Under the MCP Sampling pattern:

  1. The server dispatches a sampling/createMessage request back to the client.
  2. The client intercepts the request, validates token allowances, and queries the LLM using the client’s own managed credentials.
  3. The client returns the completion to the server.

This keeps servers completely decoupled from model providers and guarantees that API keys and token budgets remain under the client’s unified control.


Summary

Building an MCP client transitions your architecture from passive tool consumption to active agent orchestration:

  • Universal compatibility: Your agent can connect to any standardized server across the ecosystem without writing one-off integrations.
  • Dynamic discovery: Adding or updating tools requires zero changes to your agent prompt or dispatch logic.
  • Centralized governance: Token consumption, security boundaries (Roots), and credential isolation remain strictly managed at the client layer.