FastMCP: Build Your Own MCP Server in Python in Minutes
Hey everyone, today we build our own MCP server using FastMCP. If you have already used someone else's MCP server in Claude Desktop or your editor and now wonder how to build one so AI can reach your internal database, your company API, or your team's custom tooling, this tutorial is for you.
In short, MCP (Model Context Protocol) is a standard protocol that lets AI applications connect to external data and tools. People often describe it as USB-C for AI: one standard connector, many devices. Before MCP, every integration had to be built specifically for each application. With MCP you write the server once and it works with Claude Desktop, Claude Code, and any other application supporting the protocol.
FastMCP is a Python framework that makes building MCP servers very simple. If you have used FastAPI, this will feel immediately familiar, because the style is the same: decorators on ordinary Python functions.
Introduction
The Three MCP Primitives
Before coding, understand the three things an MCP server can provide.
Tools are functions the model can call to do something. This is the most used primitive. Examples: search a database, send an email, create a ticket, compute something. Think of them as POST endpoints in a REST API, since they can have side effects.
Resources are data the model can read. Examples: file contents, prepared query results, configuration. Think of them as GET endpoints, ideally side effect free. Resources are usually selected by the user or application rather than chosen by the model.
Prompts are conversation templates a user can invoke. Examples: a code review template or a report analysis template. These surface as ready-made commands in the client application.
For most practical use cases you will mostly write Tools. So this tutorial focuses there, though I will show the other two as well.
Transport: STDIO or HTTP
There are two ways an MCP server talks to a client.
STDIO means the client launches your server as a child process and communicates over standard input and output. This is what Claude Desktop uses for local servers. Simple, safe, no network.
HTTP means your server runs as a web service and clients connect over the network. That is what you use when the server is deployed centrally and shared.
Rule of thumb: use STDIO for personal tools or tools touching local files, use HTTP for tools shared by a team.
Installation
python -m venv venv
source venv/bin/activate # Linux / Mac
venv\Scripts\activate # Windows
pip install fastmcp
Check the version:
fastmcp version
Your First Server
Let us start with the simplest thing that works.
# server.py
from fastmcp import FastMCP
mcp = FastMCP("Team Utilities")
@mcp.tool
def add(a: float, b: float) -> float:
"""Add two numbers together."""
return a + b
@mcp.tool
def countbusinessdays(startdate: str, enddate: str) -> int:
"""Count business days (Monday through Friday) between two dates.
Args:
startdate: format YYYY-MM-DD
enddate: format YYYY-MM-DD
"""
from datetime import date, timedelta
start = date.fromisoformat(startdate)
end = date.fromisoformat(enddate)
count = 0
cursor = start
while cursor <= end:
if cursor.weekday() < 5:
count += 1
cursor += timedelta(days=1)
return count
if name == "main":
mcp.run()
Done. That is a fully functional MCP server.
Two details in that code deserve close attention, because they determine whether your tools get used correctly.
First, type hints. FastMCP uses Python type annotations to generate the JSON schema sent to the model. Writing a: float tells the model that parameter must be a number. Without a type, the model has to guess, and it often guesses wrong.
Second, docstrings. The docstring is the description the model reads to decide when to call your tool. Write it clearly and specifically. "Add two numbers together" is far more useful than "add function". And state expected formats as shown above, because the model cannot guess which date format you want.
Running and Testing
To test before connecting to any application:
fastmcp dev server.py
That opens the MCP Inspector in your browser, where you can list tools, call them manually, and inspect responses. It is the fastest debugging loop, and I recommend always testing there before wiring into Claude.
To connect to Claude Desktop:
fastmcp install claude-desktop server.py
Or add it manually to the Claude Desktop config file:
{
"mcpServers": {
"team-utilities": {
"command": "python",
"args": ["/full/path/to/server.py"]
}
}
}
Restart Claude Desktop and your tools appear.
A More Realistic Tool: Database Access
The addition example is too simple. Here is something closer to real work.
import sqlite3
from typing import Literal
from fastmcp import FastMCP
mcp = FastMCP("Sales Database")
DB = "sales.db"
def connect():
conn = sqlite3.connect(DB)
conn.rowfactory = sqlite3.Row
return conn
@mcp.tool
def searchcustomers(keyword: str, limit: int = 10) -> list[dict]:
"""Search customers by name or email.
Args:
keyword: text searched in the name and email columns
limit: maximum number of results, default 10
"""
with connect() as conn:
rows = conn.execute(
"""
SELECT id, name, email, city, lifetimevalue
FROM customers
WHERE name LIKE ? OR email LIKE ?
ORDER BY lifetimevalue DESC
LIMIT ?
""",
(f"%{keyword}%", f"%{keyword}%", limit),
).fetchall()
return [dict(r) for r in rows]
@mcp.tool
def salessummary(
period: Literal["daily", "weekly", "monthly"] = "monthly",
periods: int = 6,
) -> list[dict]:
"""Get aggregated sales totals per period.
Args:
period: aggregation granularity
periods: how many recent periods to return
"""
timeformat = {
"daily": "%Y-%m-%d",
"weekly": "%Y-%W",
"monthly": "%Y-%m",
}[period]
with connect() as conn:
rows = conn.execute(
f"""
SELECT strftime('{timeformat}', date) AS period,
COUNT() AS transactioncount,
SUM(total) AS totalsales
FROM transactions
GROUP BY period
ORDER BY period DESC
LIMIT ?
""",
(periods,),
).fetchall()
return [dict(r) for r in rows]
if name == "main":
mcp.run()
Note the Literal type for period. It tells the model there are exactly three valid values, so it will not invent others. For parameters with a fixed set of options, always use Literal or an Enum.
Also note that I use parameterized queries rather than f-strings for values. Values coming from the model must be treated like user input: never interpolate them straight into SQL. This matters a lot, because a model can produce any parameter it likes.
A Tool That Calls an External API
import os
import httpx
from fastmcp import FastMCP
mcp = FastMCP("CRM Integration")
BASEURL = "https://api.internal-crm.com/v1"
APIKEY = os.environ["CRMAPIKEY"]
@mcp.tool
async def createticket(title: str, description: str, priority: str = "normal") -> dict:
"""Create a new support ticket in the CRM.
Args:
title: short ticket title
description: full description of the issue
priority: low, normal, high, or critical
"""
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{BASEURL}/tickets",
headers={"Authorization": f"Bearer {APIKEY}"},
json={"title": title, "description": description, "priority": priority},
)
resp.raiseforstatus()
data = resp.json()
return {
"ticketid": data["id"],
"url": data["weburl"],
"status": data["status"],
}
FastMCP supports async functions natively, and for tools doing network calls async is the right choice so your server does not block.
Note that I return a trimmed dictionary rather than the whole raw API response. That is good practice. API responses often carry dozens of irrelevant fields, and all of them consume tokens in the model's context. Return only what is useful.
Resources: Readable Data
from fastmcp import FastMCP
mcp = FastMCP("Internal Documentation")
@mcp.resource("docs://leave-policy")
def leavepolicy() -> str:
"""The currently effective employee leave policy."""
with open("documents/leavepolicy.md", encoding="utf-8") as f:
return f.read()
@mcp.resource("data://customers/{customerid}")
def customerprofile(customerid: str) -> dict:
"""Full profile of a customer by ID."""
with connect() as conn:
row = conn.execute(
"SELECT FROM customers WHERE id = ?", (customerid,)
).fetchone()
if row is None:
raise ValueError(f"customer {customerid} not found")
return dict(row)
The second resource uses a URI template with a parameter, letting clients fetch specific records through a consistent addressing scheme.
Prompts: Ready-Made Templates
@mcp.prompt
def complaintanalysis(complainttext: str) -> str:
"""Template for analyzing a customer complaint in a structured way."""
return f"""Analyze the following customer complaint and provide:
Problem category
Urgency level with justification
Department that should handle it
Suggested first response to the customer
Complaint:
{complainttext}
"""
This appears in the client application as a command the user can select, typically through a slash command menu.
Handling Errors Properly
This part is often neglected despite mattering a lot for interaction quality.
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
mcp = FastMCP("Safe Server")
@mcp.tool
def getreport(reportid: str) -> dict:
"""Fetch a report by ID."""
if not reportid.startswith("RPT-"):
raise ToolError("Report IDs must start with 'RPT-', for example RPT-2026-001")
report = database.find(reportid)
if report is None:
raise ToolError(f"Report {reportid} not found. "
f"Use the searchreports tool to list available reports.")
return report
ToolError is a message you intentionally show the model. Notice it does not just say "error", it tells the model what went wrong and what to do next. The model reads that and can fix its own call. This is the difference between an agent that gets stuck and one that recovers.
By contrast, an uncaught ordinary exception produces a generic error message that helps nobody.
Running as an HTTP Server
For centralized deployment:
if name == "main":
mcp.run(transport="http", host="0.0.0.0", port=8000)
Clients then connect to http://your-server:8000/mcp.
If you run this on a network many people can reach, think about authentication. FastMCP supports auth middleware, and at minimum you should place it behind an authenticating reverse proxy. Never expose a data-mutating MCP server to the open internet unprotected.
Testing Your Server
Testing matters because your tools will be called by a model with parameters you did not necessarily imagine.
import pytest
from fastmcp import Client
from server import mcp
@pytest.mark.asyncio
async def testcountbusinessdays():
async with Client(mcp) as client:
result = await client.calltool(
"countbusinessdays",
{"startdate": "2026-09-01", "enddate": "2026-09-30"},
)
assert result.data == 22
@pytest.mark.asyncio
async def testtoollisting():
async with Client(mcp) as client:
tools = await client.listtools()
names = [t.name for t in tools]
assert "countbusiness_days" in names
FastMCP's built-in client can connect directly to the in-memory server object, so tests run fast with no separate process.
Tips and Best Practices
Write docstrings as though explaining to a new colleague. The model reads them to decide when to use your tool. A poor docstring means a tool that is rarely used or used wrongly.
Always use specific type hints. Literal["daily", "weekly"] is far better than str.
Return compact, cleaned data. Every unnecessary field is a wasted token and a source of confusion.
Do not cram too many tools into one server. With 40 tools the model struggles to choose. Group them into several domain-specific servers, or merge similar tools behind a mode parameter.
Treat every parameter from the model as untrusted input. Validate, bound, and use parameterized queries. A model can produce any value at all.
Bound tools that could return large result sets. Always offer a limit parameter with a sane default.
For tools that mutate data or have serious side effects, consider a confirmation pattern: a separate preview tool showing what would change, before the tool that actually executes it.
Log every tool invocation with its parameters. When something looks strange, those logs will save you.
Conclusion
FastMCP turns MCP server development into a task of minutes rather than days. The summary:
MCP standardizes how AI applications connect to tools and data, so you write a server once and many applications can use it.
Tools for actions, Resources for readable data, Prompts for templates. Most practical needs are met with Tools.
Type hints and docstrings are not optional documentation, they are the interface the model reads to use your tool correctly.
ToolError with an informative message lets the model correct its own mistakes.
Use STDIO for local tools and HTTP for shared ones, and do not forget authentication when the server is network reachable.
Test with fastmcp dev before connecting to any application, and write automated tests with the built-in client.
Start with one repetitive task on your team, maybe looking up order status or pulling a weekly metrics summary. Wrap it as a single MCP tool, connect it to Claude, and feel the difference when AI can reach your real company data instead of guessing. Happy building.