Building an MCP (Model Context Protocol) server from scratch is more work than it looks. stdio transport handling, JSON-RPC 2.0 serialization, handler registration — 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. Here's what I found.
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: "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 — 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.
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 — 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
Serverinstance - Register
@server.list_tools()and@server.call_tool()separately - Manually parse input parameters
- Combine
anyio.run()+stdio_server()to run
FastMCP approach:
- One
FastMCPinstance -
@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.
Summary
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 — check the FastMCP official docs for the latest API before you ship anything.

Top comments (0)