Building a Python MCP Server in 30 Minutes with FastMCP 3.x — One @tool Decorator Is All You Need
8 min read

Building a Python MCP Server in 30 Minutes with FastMCP 3.x — One @tool Decorator Is All You Need

Ship a Python MCP server in 30 lines with FastMCP 3.2.4. The @mcp.tool(), @mcp.resource(), and @mcp.prompt() decorators expose tools Claude and Cursor call.

Building an MCP (Model Context Protocol) server from scratch is more work than it looks. You handle the stdio transport, serialize JSON-RPC 2.0, then register every handler by hand. If you’ve gone through implementing an MCP server with Streamable HTTP, you know the moment where you think: “I just want to add one AI tool, why does this need so much boilerplate?”

FastMCP exists to fix that. Today I installed it in a sandbox via pip and had a working MCP server running in under 30 minutes.

This post is about the FastMCP tool, not the MCP protocol itself. If you want the protocol background, keep the Model Context Protocol official site open, and for FastMCP’s source and changelog the jlowin/fastmcp GitHub repo is the canonical reference. Building the same kind of server in TypeScript follows a different path, which I wrote up separately in the MCP server TypeScript SDK step-by-step guide.

What FastMCP Actually Is

FastMCP is a high-level layer on top of the MCP Python SDK, similar to how Express.js wraps Node’s http module. The official tagline reads “The fast, Pythonic way to build MCP servers and clients.” After hands-on testing, I’d say that’s accurate.

Version check first:

$ fastmcp version

FastMCP version:   3.2.4
MCP version:       1.27.0
Python version:    3.12.8
Platform:          macOS-15.6-arm64

My backlog had this noted as “v2.0,” but it’s already at 3.x. The MCP protocol itself is at 1.27.0. This version gap means one thing: the API has changed, and docs don’t always reflect that. I had to verify things directly by running code rather than trusting older articles.

Install and First Server: This Really Is All of It

pip install fastmcp

Installation takes about ten seconds. Here’s the first server I built in the sandbox, two weather-related tools:

from fastmcp import FastMCP
from datetime import datetime

mcp = FastMCP("weather-tools", version="1.0.0")

@mcp.tool()
def get_current_time(timezone: str = "UTC") -> str:
    """Returns the current time."""
    return f"Current time ({timezone}): {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"

@mcp.tool()
def calculate_temp(celsius: float) -> dict:
    """Converts Celsius to Fahrenheit and Kelvin."""
    return {
        "celsius": celsius,
        "fahrenheit": round(celsius * 9/5 + 32, 2),
        "kelvin": round(celsius + 273.15, 2)
    }

@mcp.resource("data://server-info")
def server_info() -> str:
    """Returns server info."""
    return "FastMCP 3.x weather server"

@mcp.prompt()
def weather_analysis(location: str) -> str:
    """Weather analysis prompt template."""
    return f"Analyze the weather in {location} and recommend appropriate clothing."

if __name__ == "__main__":
    mcp.run()  # runs in stdio mode

That’s it. Add a decorator to a Python function and it becomes an MCP tool. Type hints are automatically converted to JSON Schema and passed to Claude.

Inspect the server with the CLI:

$ fastmcp inspect server.py

Server
  Name:         weather-tools
  Version:      1.0.0
  Generation:   2

Components
  Tools:        2
  Prompts:      1
  Resources:    1
  Templates:    0

Three Building Blocks: Tool, Resource, Prompt

FastMCP has three core abstractions. Getting these right is what makes a well-designed server.

@mcp.tool(): A function Claude can directly invoke. It takes parameters, does work, and returns results. Search, compute, file operations, API calls: anything with execution behavior goes here. If I want Claude to interact with my filesystem or an external API, @mcp.tool() is the answer.

@mcp.resource(): A read-only data source. Register it with a URI like data://, file://, or https://, and Claude reads it as context. Unlike tools, this is “read” not “execute.” Database schemas, config files, documentation: put these here and they flow into Claude’s context window.

@mcp.prompt(): A reusable prompt template. Takes parameters, returns a structured prompt message. Works like a slash command in Claude Desktop or claude.ai.

The Tool vs Resource distinction trips people up. My rule: if it has side effects, it’s a Tool; if it’s read-only, it’s a Resource.

Sending Progress Updates with Context

When a tool runs a long operation, you can stream progress back to the client in real time. Add a Context parameter and FastMCP injects it automatically.

from fastmcp import FastMCP, Context

mcp = FastMCP("dev-tools")

@mcp.tool()
async def list_files(directory: str, ctx: Context) -> list[str]:
    """Returns a list of files in the specified directory."""
    import os
    await ctx.info(f"Reading directory: {directory}")  # streams log to client
    
    try:
        files = os.listdir(directory)
        await ctx.report_progress(100, 100, "complete")
        return sorted(files)
    except FileNotFoundError:
        raise ValueError(f"Directory not found: {directory}")

I ran this in the sandbox and confirmed that ctx.info() actually streams to the client side:

INFO  Received INFO from server: {'msg': 'Reading directory: /tmp', 'extra': None}

When this works inside Claude Desktop, users see real-time feedback about what the tool is doing. It’s a meaningful UX improvement for long-running operations.

Testing with FastMCP Client

You don’t need an actual Claude Desktop to test. FastMCP provides an in-process client. This is also handy when implementing agentic workflow patterns, since tests stay self-contained.

import asyncio
from fastmcp import FastMCP
from fastmcp.client import Client

mcp = FastMCP("dev-tools")

@mcp.tool()
def search_text(text: str, pattern: str) -> dict:
    """Searches for a pattern in text."""
    import re
    matches = re.findall(pattern, text)
    return {"pattern": pattern, "matches": matches, "count": len(matches)}

@mcp.tool()
def word_count(text: str) -> dict:
    """Returns word count, character count, and line count."""
    words = text.split()
    return {
        "words": len(words),
        "characters": len(text),
        "lines": len(text.splitlines())
    }

async def test():
    async with Client(mcp) as client:
        tools = await client.list_tools()
        print(f"Registered tools ({len(tools)}):")
        for t in tools:
            print(f"  [{t.name}] {t.description}")
        
        result = await client.call_tool("search_text", {
            "text": "FastMCP is fast. FastMCP is easy.",
            "pattern": "FastMCP"
        })
        print(f"\nsearch_text result: {result.data}")
        # → {'pattern': 'FastMCP', 'matches': ['FastMCP', 'FastMCP'], 'count': 2}
        
        result2 = await client.call_tool("word_count", {
            "text": "Hello World from FastMCP 3.x"
        })
        print(f"word_count result: {result2.data}")
        # → {'words': 5, 'characters': 27, 'lines': 1}

asyncio.run(test())

Access the structured return value directly through result.data. Ran this in the sandbox, zero errors.

FastMCP CLI output — fastmcp version, inspect, tool call test

HTTP Deployment for Remote Access

Beyond local stdio mode, you can run the server over HTTP. Useful when sharing an MCP server across Cursor instances or deploying remotely.

# HTTP mode (default port 8000)
if __name__ == "__main__":
    mcp.run(transport="http", host="0.0.0.0", port=8000)
# Or run directly with uvicorn
uvicorn server:mcp.http_app() --host 0.0.0.0 --port 8000

The FastMCP HTTP app is Starlette-based (StarletteWithLifespan under the hood). That means you can mount it inside a FastAPI app:

from fastapi import FastAPI
from fastmcp import FastMCP

app = FastAPI()
mcp = FastMCP("my-tools")

@mcp.tool()
def my_tool() -> str:
    return "result"

app.mount("/mcp", mcp.http_app())

Connecting Claude Desktop to the HTTP server:

{
  "mcpServers": {
    "my-tools": {
      "url": "http://localhost:8000/mcp/"
    }
  }
}

The fastmcp CLI

FastMCP ships with a CLI that I didn’t notice at first. Running fastmcp --help reveals quite a bit:

Commands:
  inspect      — Print server component summary
  list         — List registered tools
  call         — Directly call a tool (useful for debugging)
  install      — Auto-register to Claude Desktop / Cursor
  dev          — Run dev server with hot reload
  discover     — Find MCP servers configured in editors
  run          — Start the server

fastmcp install server.py --client claude is supposed to automatically patch your Claude Desktop config. No more hand-editing JSON. I couldn’t verify this directly since I don’t have Claude Desktop installed in my sandbox environment, so check the official docs for exactly which config path it touches.

The fastmcp dev command seems more immediately useful: hot reload during development means no manual server restarts as you iterate.

Type Hints Are Your API Schema

The feature I found most impressive: type hints become JSON Schema automatically. With the raw SDK, you write an inputSchema dict for every tool by hand. FastMCP delegates that to Python’s type system.

from typing import Literal
from pydantic import BaseModel

class FileFilter(BaseModel):
    extension: str
    min_size_kb: int = 0
    exclude_hidden: bool = True

@mcp.tool()
def list_files_advanced(
    directory: str,
    filter: FileFilter | None = None,
    sort_by: Literal["name", "size", "modified"] = "name",
    limit: int = 50
) -> list[dict]:
    """Returns filtered and sorted file listing."""
    import os
    files = []
    for f in os.scandir(directory):
        if filter and filter.exclude_hidden and f.name.startswith("."):
            continue
        if filter and not f.name.endswith(f".{filter.extension}"):
            continue
        info = f.stat()
        size_kb = info.st_size / 1024
        if filter and size_kb < filter.min_size_kb:
            continue
        files.append({"name": f.name, "size_kb": round(size_kb, 2), "modified": info.st_mtime})
    key_map = {"name": "name", "size": "size_kb", "modified": "modified"}
    files.sort(key=lambda x: x[key_map[sort_by]])
    return files[:limit]

Register this with @mcp.tool() and Claude automatically knows the structure of FileFilter, the valid values for sort_by (name/size/modified), and limit’s default. Pydantic models work too, so complex nested inputs don’t need any extra wiring.

The docstring becomes the tool description Claude sees. A well-written docstring is the usage manual you send to the model.

A Real-World Example: Code Analysis MCP Server

Here’s something I’d actually ship. A Python code analysis tool server:

from fastmcp import FastMCP, Context
import ast
import os

mcp = FastMCP("code-analyzer", version="1.0.0")

@mcp.tool()
async def analyze_python_file(filepath: str, ctx: Context) -> dict:
    """Analyzes a Python file with AST and returns functions and classes."""
    await ctx.info(f"Analyzing: {filepath}")
    if not os.path.exists(filepath):
        raise ValueError(f"File not found: {filepath}")
    with open(filepath, "r", encoding="utf-8") as f:
        source = f.read()
    tree = ast.parse(source)
    functions, classes = [], []
    for node in ast.walk(tree):
        if isinstance(node, ast.FunctionDef):
            functions.append({
                "name": node.name, "line": node.lineno,
                "args": [a.arg for a in node.args.args],
                "docstring": ast.get_docstring(node)
            })
        elif isinstance(node, ast.ClassDef):
            classes.append({"name": node.name, "line": node.lineno})
    await ctx.report_progress(100, 100, "complete")
    return {"total_lines": source.count("\n") + 1, "functions": functions, "classes": classes}

@mcp.tool()
def count_todo_comments(filepath: str) -> dict:
    """Finds TODO/FIXME/HACK comments in a file."""
    markers = ["TODO", "FIXME", "HACK", "XXX"]
    results = {m: [] for m in markers}
    with open(filepath, "r", encoding="utf-8") as f:
        for i, line in enumerate(f, 1):
            for marker in markers:
                if f"# {marker}" in line:
                    results[marker].append({"line": i, "text": line.strip()})
    return {k: v for k, v in results.items() if v}

@mcp.resource("data://project-structure")
def project_structure() -> str:
    """Returns Python file list in the current directory."""
    py_files = []
    for root, dirs, files in os.walk("."):
        dirs[:] = [d for d in dirs if not d.startswith(".")]
        for f in files:
            if f.endswith(".py"):
                py_files.append(os.path.join(root, f))
    return "\n".join(py_files[:50])

if __name__ == "__main__":
    mcp.run()

Connect this to Claude Desktop and you can ask in plain English: “Show me all classes in this file” or “How many TODO comments are there?” No Python required from the user’s side. That’s the point of an MCP tool server.

FastMCP vs Raw MCP SDK

Compare with building a Streamable HTTP MCP server directly:

Raw SDK approach:

  • Create a Server instance
  • Register @server.list_tools() and @server.call_tool() separately
  • Manually parse input parameters
  • Combine anyio.run() + stdio_server() to run

FastMCP approach:

  • One FastMCP instance
  • @mcp.tool() registers functions directly as tools
  • JSON Schema auto-generated from type hints
  • mcp.run(): one line

Fewer lines is secondary. The real point: you focus on business logic, not transport mechanics.

That said, FastMCP trades off control for convenience. If you need to customize low-level MCP messages, use non-standard transports, or access MCP features FastMCP hasn’t exposed, you’ll end up digging under the abstraction. In those cases, reach for the MCP Python SDK directly, like in MCP code execution scenarios that need finer control.

When to Use FastMCP

My practical take:

Use FastMCP when: You’re building a server for standard MCP clients (Claude, Cursor, VS Code). Especially for rapid AI tool prototyping, or exposing existing Python functions as MCP tools for your team.

Use the raw SDK when: You need custom transport, non-standard message formats, or MCP features FastMCP hasn’t wrapped. Performance-critical paths where every layer matters.

One honest complaint about FastMCP: 3.x moved faster than the docs. I found get_tools() referenced in older content but it doesn’t exist — list_tools() is the actual method. Trust dir(mcp) and the source code over older blog posts. Including mine.

Before going to production, also look at MCP Gateway for controlling which tools agents can call. Once you’ve exposed a server, you’ll want some control over what actually gets invoked and when.

When to Reach for It, and When to Skip It

Plenty of posts recommend a tool. Few are honest about when not to use it. Here’s the line I drew after actually running it.

FastMCP fits well when

  • You need a server for a standard MCP client (Claude Desktop, Cursor, VS Code) and you want it fast.
  • You want to expose existing Python functions as AI tools. A decorator is the whole job.
  • You’re prototyping inside a team and “working now” beats “perfectly controlled.” You don’t want to spend time on transport plumbing.
  • Your inputs are complex and you’d rather not hand-write an inputSchema. Type hints and Pydantic cover it.

You’re better off skipping it when

  • You have to touch low-level MCP messages or need a non-standard transport. Here the abstraction gets in the way, and using the MCP Python SDK directly is the right call.
  • Your primary runtime isn’t Python. On Node/TypeScript the MCP server TypeScript SDK step-by-step guide is the more natural fit.
  • You want to keep everything local and private, model included. I covered that setup in building a private MCP server with Gemma 3 and FastMCP.
  • You’re in a regulated or audited environment where every behavior inside the framework abstraction has to be traceable. Thin dependencies are safer there.

One line: standard clients and speed, pick FastMCP; raw protocol control, pick the SDK.

A Working Server in 30 Minutes, and What Comes Next

FastMCP 3.x is the fastest path for a Python developer to ship an MCP server. One pip install fastmcp, one @mcp.tool() decorator, one mcp.run(). Under 30 minutes to a working AI tool server that Claude Desktop can call.

MCP’s ecosystem is maturing fast. My MCP server toolkit covers what’s already available before you build your own. Check there first — but if you need something custom, FastMCP makes building it genuinely quick.

Verified versions today: FastMCP 3.2.4, MCP 1.27.0. This space moves fast, so check the FastMCP official docs for the latest API before you ship anything.

Frequently Asked Questions

How do I build an MCP server with FastMCP?
Install it with pip install fastmcp, create a FastMCP instance, and add the @mcp.tool() decorator to a Python function. Call mcp.run() at the end and the server runs in stdio mode. As the post shows, you can have a working server in under 30 lines of Python.
What is the difference between @mcp.tool and @mcp.resource?
@mcp.tool() is a function Claude invokes directly to do work such as search, compute, or file operations. @mcp.resource() is a read-only data source registered with a URI like data://, file://, or https:// that Claude reads as context. The post's rule is simple: if it has side effects it is a Tool, if it is read-only it is a Resource.
Why do type hints matter in FastMCP?
FastMCP converts a function's type hints into JSON Schema automatically and passes it to Claude. It supports Pydantic models and Literal types, so you never hand-write an inputSchema for complex nested inputs. The docstring becomes the tool description the model sees.
When should I use the raw MCP Python SDK instead of FastMCP?
Use FastMCP when building servers for standard clients like Claude, Cursor, and VS Code, or to quickly expose existing Python functions. Reach for the MCP Python SDK directly when you need custom transports, non-standard message formats, or low-level MCP features that FastMCP hides behind its abstraction.

Read in Other Languages

Was this helpful?

Your support helps me create better content. Buy me a coffee.

About the Author

jw

Kim Jangwook

Full-Stack Developer specializing in AI/LLM

Building AI agent systems, LLM applications, and automation solutions with 10+ years of web development experience. Sharing practical insights on Claude Code, MCP, and RAG systems.

Back to Blog