FastMCP: Pythonic way to create MCP servers and clients

by
0 comments
FastMCP: Pythonic way to create MCP servers and clients

Image by author

# Introduction

Model Context Protocol (MCP) has changed how large language models (LLMs) interact with external tools, data sources, and services. However, building an MCP server from scratch traditionally requires navigating complex boilerplate code and detailed protocol specifications. fastmcp Eliminates this barrier by providing a decorator-based, Pythonic framework that enables developers to create production-ready MCP servers and clients with minimal code.

In this tutorial, you will learn how to create an MCP server and client using FastMCP, complete with comprehensive and error handling, making it ideal for both beginners and intermediate developers.

// Prerequisites

Before starting this tutorial, make sure you have:

  • Python 3.10 or higher (3.11+ recommended for better async performance)
  • PIP or UV (UV is recommended for FastMCP deployments and required for CLI tools)
  • A code editor (I’m using VS Code, but you can use any editor you like)
  • Terminal/Command Line Information for Running Python Scripts

It is also beneficial to have a good knowledge of Python programming (functions, decorators, type hints), some understanding of async/await syntax (optional, but useful for advanced examples), familiarity with JSON and REST API concepts, and basic command-line terminal usage.

Before FastMCP, building an MCP server required you to have a deep understanding of the MCP JSON-RPC specification, extensive boilerplate code for protocol handling, manual connection and transport management, and complex error handling and validation logic.

FastMCP addresses these issues with intuitive decorators and a simple, Pythonic API, enabling you to focus on business logic rather than protocol implementation.

# What is model reference protocol?

Model Context Protocol (MCP) is an open standard created by Anthropic. It provides a universal interface for AI applications to securely connect to external tools, data sources, and services. MCP standardizes how LLMs interact with external systems, just as Web APIs standardize web service communication.

// Key Features of MCP

  • Standardized Communication: Uses JSON-RPC 2.0 for reliable, structured messaging
  • Bidirectional: Supports both requests and responses from client to server
  • Security: Built-in support for authentication and authorization patterns
  • Flexible Transportation: Works with any transport mechanism (stdio, HTTP, WebSocket, SSE)

// MCP Architecture: Server and Client

MCP follows a clear client-server architecture:

MCP client-server architecture
Image by author

  • MCP Server: Exposes capabilities (devices, resources, signals) that can be used by external applications. Think of it as a backend API designed specifically for LLM integration.
  • MCP Client: Embedded in AI applications (such as cloud desktops, cursor IDEs, or custom applications) that connect to MCP servers to access their resources.

// Main components of MCP

MCP servers expose three primary types of capabilities:

  1. tool: Executable functions that the LLM can call to perform actions. Tools can query databases, call APIs, perform calculations, or trigger workflows.
  2. resources: Read-only data that MCP clients can obtain and use as reference. Resources can be file content, configuration data, or dynamically generated content.
  3. Signal: Reusable message templates that guide LLM behavior. Signals provide coherent instructions for multi-step operations or special logic.

# What is FastMCP?

FastMCP is a high-level Python framework that simplifies the process of building both MCP servers and clients. Designed to reduce development headaches, FastMCP has the following features:

  • Decorator-Based API: Python decorators (@mcp.tool, @mcp.resource, @mcp.prompt) remove the boilerplate
  • Security Type: Complete type hinting and validation using Python’s type system
  • Async/await support: Modern async Python for high-performance operations
  • Multiple Transportation: Support for stdio, HTTP, WebSocket and SSE
  • Built-in Test: Easy client-server testing without subprocess complexity
  • Production Ready: Features like error handling, logging, and configuration for production deployments

// FastMCP philosophy

FastMCP relies on three main principles:

  1. High level abstraction: Less code and faster development cycles
  2. Easy: Minimal boilerplate allows focus on functionality over protocol details
  3. Pythonic: Natural Python Idioms Make It Familiar to Python Developers

# installation

Start by installing FastMCP and the required dependencies. I recommend using ultraviolet.

If you don’t have uv, install it with pip:

Or install fastmcp directly with pip:

Verify that FastMCP is installed:

python -c "from fastmcp import FastMCP; print('FastMCP installed successfully')"

# Creating Your First MCP Server

We will create a practical MCP server that will display tools, resources, and hints. We will create a calculator server that provides mathematical operations, configuration resources, and instruction prompts.

// Step 1: Establishing the Project Structure

We first need to create a project directory and initialize your environment. Create a folder for your project:

Then navigate to your project folder:

Start your project with the required files:

// Step 2: Creating the MCP Server

Our Calculator MCP Server is a simple MCP server that displays tools, resources and hints. Inside your project folder, create a file named calculator_server.py And add the following code.

import logging
import sys
from typing import Dict
from fastmcp import FastMCP

# Configure logging to stderr (critical for MCP protocol integrity)
logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    stream=sys.stderr
)
logger = logging.getLogger(__name__)

# Create the FastMCP server instance
mcp = FastMCP(name="CalculatorServer")

Server imports FastMCP and configures logging stderr. The MCP protocol requires that all output except protocol messages be directed to stderr to prevent communication corruption. FastMCP(name="CalculatorServer") Creates a call server instance. It handles all protocol management automatically.

Now, let’s create our tools.

@mcp.tool
def add(a: float, b: float) -> float:
    """
    Add two numbers together.
   
    Args:
        a: First number
        b: Second number
       
    Returns:
        Sum of a and b
    """
    try:
        result = a + b
        logger.info(f"Addition performed: {a} + {b} = {result}")
        return result
    except TypeError as e:
        logger.error(f"Type error in add: {e}")
        raise ValueError(f"Invalid input types: {e}")

@mcp.tool
def subtract(a: float, b: float) -> float:
    """
    Subtract b from a.
   
    Args:
        a: First number (minuend)
        b: Second number (subtrahend)
       
    Returns:
        Difference of a and b
    """
    try:
        result = a - b
        logger.info(f"Subtraction performed: {a} - {b} = {result}")
        return result
    except TypeError as e:
        logger.error(f"Type error in subtract: {e}")
        raise ValueError(f"Invalid input types: {e}")

We have defined functions for addition and subtraction. Both are wrapped in a try-catch block to raise value errors, log information, and return results.

@mcp.tool
def multiply(a: float, b: float) -> float:
    """
    Multiply two numbers.
   
    Args:
        a: First number
        b: Second number
       
    Returns:
        Product of a and b
    """
    try:
        result = a * b
        logger.info(f"Multiplication performed: {a} * {b} = {result}")
        return result
    except TypeError as e:
        logger.error(f"Type error in multiply: {e}")
        raise ValueError(f"Invalid input types: {e}")

@mcp.tool
def divide(a: float, b: float) -> float:
    """
    Divide a by b.
   
    Args:
        a: Dividend (numerator)
        b: Divisor (denominator)
       
    Returns:
        Quotient of a divided by b
       
    Raises:
        ValueError: If attempting to divide by zero
    """
    try:
        if b == 0:
            logger.warning(f"Division by zero attempted: {a} / {b}")
            raise ValueError("Cannot divide by zero")
       
        result = a / b
        logger.info(f"Division performed: {a} / {b} = {result}")
        return result
    except (TypeError, ZeroDivisionError) as e:
        logger.error(f"Error in divide: {e}")
        raise ValueError(f"Division error: {e}")

Four decorated ceremonies (@mcp.tool) highlight mathematical operations. Each device includes:

  • Type prompts for parameters and return values
  • Extensive docstrings (MCP uses these as tool descriptions)
  • Error handling try-except blocks
  • Logging for debugging and monitoring
  • input validation

Let’s move on to building resources.

@mcp.resource("config://calculator/settings")
def get_settings() -> Dict:
    """
    Provides calculator configuration and available operations.
   
    Returns:
        Dictionary containing calculator settings and metadata
    """
    logger.debug("Fetching calculator settings")
   
    return {
        "version": "1.0.0",
        "operations": ("add", "subtract", "multiply", "divide"),
        "precision": "IEEE 754 double precision",
        "max_value": 1.7976931348623157e+308,
        "min_value": -1.7976931348623157e+308,
        "supports_negative": True,
        "supports_decimals": True
    }

@mcp.resource("docs://calculator/guide")
def get_guide() -> str:
    """
    Provides a user guide for the calculator server.
   
    Returns:
        String containing usage guide and examples
    """
    logger.debug("Retrieving calculator guide")

    guide = """
       
    1. **add(a, b)**: Returns a + b
       Example: add(5, 3) = 8
   
    2. **subtract(a, b)**: Returns a - b
       Example: subtract(10, 4) = 6
   
    3. **multiply(a, b)**: Returns a * b
       Example: multiply(7, 6) = 42
   
    4. **divide(a, b)**: Returns a / b
       Example: divide(20, 4) = 5.0
   
    ## Error Handling
   
    - Division by zero will raise a ValueError
    - Non-numeric inputs will raise a ValueError
    - All inputs should be valid numbers (int or float)
   
    ## Precision
   
    The calculator uses IEEE 754 double precision floating-point arithmetic.
    Results may contain minor rounding errors for some operations.
    """
   
    return guide

Two decorated ceremonies (@mcp.resource) Provide static and dynamic data:

  • config://calculator/settings: Returns metadata about the calculator
  • docs://calculator/guide: Returns a formatted user guide
  • URI format distinguishes resource types (convention: type://category/resource)

Let’s create our signs.

@mcp.prompt
def calculate_expression(expression: str) -> str:
    """
    Provides instructions for evaluating a mathematical expression.
    Args:
        expression: A mathematical expression to evaluate        
    Returns:
        Formatted prompt instructing the LLM how to evaluate the expression
    """
    logger.debug(f"Generating calculation prompt for: {expression}")

    prompt = f"""
    Please evaluate the following mathematical expression step by step:
   
    Expression: {expression}
   
    Instructions:
    1. Break down the expression into individual operations
    2. Use the appropriate calculator tool for each operation
    3. Follow order of operations (parentheses, multiplication/division, addition/subtraction)
    4. Show all intermediate steps
    5. Provide the final result
   
    Available tools: add, subtract, multiply, divide
    """
   
    return prompt.strip()

Finally, add the server startup script.

if __name__ == "__main__":
    logger.info("Starting Calculator MCP Server...")
   
    try:
        # Run the server with stdio transport (default for Claude Desktop)
        mcp.run(transport="stdio")
    except KeyboardInterrupt:
        logger.info("Server interrupted by user")
        sys.exit(0)
    except Exception as e:
        logger.error(f"Fatal error: {e}", exc_info=True)
        sys.exit(1)

@mcp.prompt The decorator creates instruction templates that guide LLM behavior for complex tasks.

Best practices for error handling include:

  • Catching specific exceptions (TypeError, ZeroDivisionError)
  • Meaningful error messages for users
  • Detailed logging for debugging
  • graceful error propagation

// Step 3: Building the MCP Client

In this step, we will demonstrate how to interact with the calculator MCP server created above. Create a new file named calculator_client.py.

import asyncio
import logging
import sys
from typing import Any
from fastmcp import Client, FastMCP

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    stream=sys.stderr
)
logger = logging.getLogger(__name__)

async def main():
    """
    Main client function demonstrating server interaction.
    """
   
    from calculator_server import mcp as server
   
    logger.info("Initializing Calculator Client...")

    try:
        async with Client(server) as client:
            logger.info("✓ Connected to Calculator Server")
           
            # DISCOVER CAPABILITIEs            
            print("n" + "="*60)
            print("1. DISCOVERING SERVER CAPABILITIES")
            print("="*60)
           
            # List available tools
            tools = await client.list_tools()
            print(f"nAvailable Tools ({len(tools)}):")
            for tool in tools:
                print(f"  • {tool.name}: {tool.description}")
           
            # List available resources
            resources = await client.list_resources()
            print(f"nAvailable Resources ({len(resources)}):")
            for resource in resources:
                print(f"  • {resource.uri}: {resource.name or resource.uri}")
           
            # List available prompts
            prompts = await client.list_prompts()
            print(f"nAvailable Prompts ({len(prompts)}):")
            for prompt in prompts:
                print(f"  • {prompt.name}: {prompt.description}")
           
            # CALL TOOLS
           
            print("n" + "="*60)
            print("2. CALLING TOOLS")
            print("="*60)
           
            # Simple addition
            print("nTest 1: Adding 15 + 27")
            result = await client.call_tool("add", {"a": 15, "b": 27})
            result_value = extract_tool_result(result)
            print(f"  Result: 15 + 27 = {result_value}")
           
            # Division with error handling
            print("nTest 2: Dividing 100 / 5")
            result = await client.call_tool("divide", {"a": 100, "b": 5})
            result_value = extract_tool_result(result)
            print(f"  Result: 100 / 5 = {result_value}")
           
            # Error case: division by zero
            print("nTest 3: Division by Zero (Error Handling)")
            try:
                result = await client.call_tool("divide", {"a": 10, "b": 0})
                print(f"  Unexpected success: {result}")
            except Exception as e:
                print(f"  ✓ Error caught correctly: {str(e)}")
           
            # READ RESOURCES
            print("n" + "="*60)
            print("3. READING RESOURCES")
            print("="*60)
           
            # Read settings resource
            print("nFetching Calculator Settings...")
            settings_resource = await client.read_resource("config://calculator/settings")
            print(f"  Version: {settings_resource(0).text}")
           
            # Read guide resource
            print("nFetching Calculator Guide...")
            guide_resource = await client.read_resource("docs://calculator/guide")
            # Print first 200 characters of guide
            guide_text = guide_resource(0).text(:200) + "..."
            print(f"  {guide_text}")
           
            # CHAINING OPERATIONS
           
            print("n" + "="*60)
            print("4. CHAINING MULTIPLE OPERATIONS")
            print("="*60)
           
            # Calculate: (10 + 5) * 3 - 7
            print("nCalculating: (10 + 5) * 3 - 7")
           
            # Step 1: Add
            print("  Step 1: Add 10 + 5")
            add_result = await client.call_tool("add", {"a": 10, "b": 5})
            step1 = extract_tool_result(add_result)
            print(f"    Result: {step1}")
           
            # Step 2: Multiply
            print("  Step 2: Multiply 15 * 3")
            mult_result = await client.call_tool("multiply", {"a": step1, "b": 3})
            step2 = extract_tool_result(mult_result)
            print(f"    Result: {step2}")
           
            # Step 3: Subtract
            print("  Step 3: Subtract 45 - 7")
            final_result = await client.call_tool("subtract", {"a": step2, "b": 7})
            final = extract_tool_result(final_result)
            print(f"    Final Result: {final}")
           
            # GET PROMPT TEMPLATE
           
            print("n" + "="*60)
            print("5. USING PROMPT TEMPLATES")
            print("="*60)
           
            expression = "25 * 4 + 10 / 2"
            print(f"nPrompt Template for: {expression}")
            prompt_response = await client.get_prompt(
                "calculate_expression",
                {"expression": expression}
            )
            print(f"  Template:n{prompt_response.messages(0).content.text}")
           
            logger.info("✓ Client operations completed successfully")
   
    except Exception as e:
        logger.error(f"Client error: {e}", exc_info=True)
        sys.exit(1)

From the above code, the client uses async with Client(server) For secure connection management. It automatically handles connection setup and cleanup.

We also need a helper function to handle the results.

def extract_tool_result(response: Any) -> Any:
    """
    Extract the actual result value from a tool response.
   
    MCP wraps results in content objects, this helper unwraps them.
    """
    try:
        if hasattr(response, 'content') and response.content:
            content = response.content(0)
            # Prefer explicit text content when available (TextContent)
            if hasattr(content, 'text') and content.text is not None:
                # If the text is JSON, try to parse and extract a `result` field
                import json as _json
                text_val = content.text
                try:
                    parsed_text = _json.loads(text_val)
                    # If JSON contains a result field, return it
                    if isinstance(parsed_text, dict) and 'result' in parsed_text:
                        return parsed_text.get('result')
                    return parsed_text
                except _json.JSONDecodeError:
                    # Try to convert plain text to number
                    try:
                        if '.' in text_val:
                            return float(text_val)
                        return int(text_val)
                    except Exception:
                        return text_val

            # Try to extract JSON result via model `.json()` or dict-like `.json`
            if hasattr(content, 'json'):
                try:
                    if callable(content.json):
                        json_str = content.json()
                        import json as _json
                        try:
                            parsed = _json.loads(json_str)
                        except _json.JSONDecodeError:
                            return json_str
                    else:
                        parsed = content.json

                    # If parsed is a dict, try common shapes
                    if isinstance(parsed, dict):
                        # If nested result exists
                        if 'result' in parsed:
                            res = parsed.get('result')
                        elif 'text' in parsed:
                            res = parsed.get('text')
                        else:
                            res = parsed

                        # If res is str that looks like a number, convert
                        if isinstance(res, str):
                            try:
                                if '.' in res:
                                    return float(res)
                                return int(res)
                            except Exception:
                                return res
                        return res

                    return parsed
                except Exception:
                    pass
        return response
    except Exception as e:
        logger.warning(f"Could not extract result: {e}")
        return response


if __name__ == "__main__":
    logger.info("Calculator Client Starting...")
    asyncio.run(main())

Looking at the above code, before using the tool, the client lists the available capabilities. await client.list_tools() Gets all tool metadata including descriptions. await client.list_resources() Searches for available resources. At the end, await client.list_prompts() Will find templates available soon.

await client.call_tool() The method does the following:

  • Takes tool name and parameters as dictionary
  • Returns a wrapped response object containing the result.
  • Integrates with error handling for tool failures

After getting the results, extract_tool_result() The helper function opens the MCP’s response format to get the actual value, handling both JSON and text responses.

The chaining operation you see above demonstrates how to use the output from one tool as input to another, enabling complex calculations across multiple tool calls.

Finally, the error handling tool catches errors (like division by zero) and logs them gracefully without crashing.

// Step 4: Running the Server and Client

You will open two terminals. On Terminal 1, you will start the server:

python calculator_server.py

You should see:

fastmcp server terminal output
Image by author

Run the client on terminal 2:

python calculator_client.py

The output will show:

fastmcp client terminal output
Image by author

# Advanced Patterns with FastMCP

While our calculator example uses basic logic, FastMCP is designed to handle complex, production-ready scenarios. As you scale your MCP server, you can benefit from:

  • Asynchronous Operation: Use async def For tools that perform I/O-bound tasks such as database queries or API calls
  • Dynamic Resources: Can accept resource arguments (e.g., resource://users/{user_id}) to quickly fetch specific data points
  • Complex type of verification: Use pedantic models or complex Python type prompts to ensure that LLM sends data in the exact format required by your backend.
  • Custom Transportation: while we experimented stdioFastMCP also supports SSE (Server-Sentred Events) for web-based integration and custom UI tools

# conclusion

FastMCP bridges the gap between the complex Model Context protocol and the clean, decorator-based developer experience expected by Python programmers. By removing the boilerplate associated with JSON-RPC 2.0 and manual transport management, it allows you to focus on what matters: Creating tools that make LLM more efficient.

In this tutorial, we covered:

  1. Main structure of MCP (server vs client)
  2. how to define tool For tasks, resources for data, and signals for instructions
  3. How to create a functional client to test and serialize your server logic

Whether you’re building a simple utility or a complex data orchestration layer, FastMCP provides the most “Pythonic” path to a production-ready agentive ecosystem.

What will you create next? check it out FastMCP Documentation To explore more advanced deployment strategies and UI integrations.

Shittu Olumide He is a software engineer and technical writer who is passionate about leveraging cutting-edge technologies to craft compelling narratives, with a keen eye for detail and the ability to simplify complex concepts. You can also find Shittu Twitter.

Related Articles

Leave a Comment