FastMCP Tutorial: Building MCP Servers in Python From Scratch

Why Build an MCP Server?
MCPs allow you to access and “plug-in” external tools and resources to LLMs in a uniform way, which is perhaps best highlighted by the thousands of servers registered at mcp.so.
Despite the high-availability of out of the box MCP servers, building your own is still a critical skill, as you are bound to encounter niche workflows that require custom servers.
With that said, in this tutorial, we will build a simple MCP server that reads documents/PDFs with Python using FastMCP - the go-to library for building MCPs in Python. By the end, you will have the foundational knowledge and larger understanding of how to build, test and deploy your own MCP server to enhance the capabilities and usefulness of AI for your specific use case.
How to Use Existing MCP Servers with Cursor or Claude Desktop
For those who are new to MCP servers, let’s quickly cover how to use an existing server before building a custom one.
Well, to begin, you need an MCP host application like Claude Desktop, or any other AI-powered IDEs that are popping up every week. I prefer Cursor as it is one of the fastest growing and frequently updated IDEs out there.
Step 1: Installing Node.js and UV Python
Once you choose your host, you must install Node.js and UV Python package manager on your machine as these are the two main ways MCP servers are installed. Here are the instructions:
- Installing Node.js (includes npm/npx)
macOS:
# Using Homebrew
brew install node
# Or download installer from https://nodejs.org/
Windows:
# Download installer from https://nodejs.org/
# Or using winget
winget install OpenJS.NodeJS
- Installing UV Python
macOS:
# Using curl
curl -sSf https://install.python-uv.org | bash
# Or using Homebrew
brew install uv
Windows:
# Using PowerShell (run as Administrator)
powershell -c "irm https://install.python-uv.org | iex"
# Or using pip
pip install uv
Verify installations with node --version
, npx --version
, and uv --version
.
Step 2: Choose a server you can immediately start using
There are a variety of resources you can visit to find a server for your needs, including mcp.so, the “Awesome MCP servers” GitHub repository, or our recent article that hand-picked 15 of the best open MCP servers in the community.
Here, we demonstrate how to add Firecrawl MCP to Cursor, which allows your IDE to scrape web pages, crawl entire websites, or pretty much do anything Firecrawl has to offer straight in your chat thread.
To get started, you need an API key from Firecrawl, which offers a generous free tier. Then, create a ~/.cursor/mcp.json
file in your home directory to make MCP servers available across your Cursor workspaces:
mkdir ~/.cursor
touch ~/.cursor/mcp.json
Then, add the following contents to the JSON file:
{
"mcpServers": {
"firecrawl-mcp": {
"command": "npx",
"args": ["-y", "firecrawl-mcp"],
"env": {
"FIRECRAWL_API_KEY": "YOUR-API-KEY"
}
}
}
}
Afterward, restarting your IDE should make the server visible in your Cursor settings:
Then, try asking Cursor to scrape any publicly accessible web page. For example, you can ask it to scrape GitHub’s trending repositories page and the MCP server will automatically figure out that it needs to scrape all repos from https://github.com/trending
webpage.
Step-by-step guide to building MCP servers with FastMCP
Building your own MCP server opens up a world of possibilities for extending LLM capabilities with custom tools, resources, and prompts. FastMCP is the recommended Python library for creating MCP servers, offering a clean, decorator-based API that handles the underlying protocol complexities. In this section, we’ll build a document parsing server that enables MCP hosts to understand PDF and DOCX files - document formats that most AI hosts can’t parse by default.
To explore the code for the server we are about to build, you can visit our GitHub repository.
0. Installing FastMCP
The first step is to install the MCP Python SDK. We’ll use UV, a modern Python package manager that’s significantly faster than pip and offers better dependency resolution:
uv add "mcp[cli]"
The [cli]
part is an optional extra that includes command-line interface tools like the MCP inspector for debugging. Using UV’s add
command automatically adds this package to your environment without having to create a virtual environment manually - it handles that for you in the background.
1. Defining tools, resources and prompts
MCP servers consist of three primary components:
- Tools: Functions that the LLM can call to perform actions (model-controlled)
- Resources: Data sources the host application can provide to the LLM (application-controlled)
- Prompts: Templates that users can invoke through UI elements (user-controlled)
Let’s start by importing the necessary modules and creating our FastMCP instance:
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.prompts import base
FastMCP is the high-level interface for interacting with the MCP protocol. It handles connection management, protocol compliance, and message routing between your code and the host application.
Next, we’ll initialize our FastMCP instance:
mcp = FastMCP("DocumentReader", dependencies=["markitdown[all]"])
We’re naming our server “DocumentReader” and specifying “markitdown[all]” as a dependency. Markitdown is a lightweight library that can convert various document formats to markdown, which is the ideal format for LLMs.
Before diving into implementation, it’s important to plan what capabilities our server will provide. For our document reader, we’ll define:
@mcp.tool()
def read_pdf(file_path: str) -> str:
"""Read a PDF file and return the text."""
return "This is a test"
@mcp.tool()
def read_docx(file_path: str) -> str:
"""Read a DOCX file and return the text."""
return "This is a test"
@mcp.resource("file://resource-name")
def always_needed_pdf():
# Return the file path
return "This PDF file is always needed."
@mcp.prompt()
def debug_pdf_path(error: str) -> list[base.Message]:
return f"I am debugging this error: {error}"
The code above defines the structure of our MCP server with placeholder implementations:
- Two tools (
read_pdf
andread_docx
) that will extract text from documents - A resource that could provide access to a frequently needed document
- A prompt that helps debug PDF-related issues
Each component uses a decorator pattern (@mcp.tool()
, @mcp.resource()
, @mcp.prompt()
) that registers the function with the MCP server. The docstrings are crucial as they provide descriptions that help the LLM understand when and how to use each component.
2. Adding tools to the server
Now that we understand the structure of our MCP server, let’s implement our document reading tools with actual functionality:
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.prompts import base
from markitdown import MarkItDown
import os
mcp = FastMCP("DocumentReader", dependencies=["markitdown[all]"])
md = MarkItDown()
@mcp.tool(
annotations={
"title": "Read PDF Document",
"readOnlyHint": True,
"openWorldHint": False
}
)
def read_pdf(file_path: str) -> str:
"""Read a PDF file and return the text content.
Args:
file_path: Path to the PDF file to read
"""
try:
# Expand the tilde (if part of the path) to the home directory path
expanded_path = os.path.expanduser(file_path)
# Use markitdown to convert the PDF to text
return md.convert(expanded_path).text_content
except Exception as e:
# Return error message that the LLM can understand
return f"Error reading PDF: {str(e)}"
@mcp.tool(
annotations={
"title": "Read Word Document",
"readOnlyHint": True,
"openWorldHint": False
}
)
def read_docx(file_path: str) -> str:
"""Read a DOCX file and return the text content.
Args:
file_path: Path to the Word document to read
"""
try:
expanded_path = os.path.expanduser(file_path)
# Use markitdown to convert the DOCX to text
return md.convert(expanded_path).text_content
except Exception as e:
return f"Error reading DOCX: {str(e)}"
The @mcp.tool()
decorator registers each function as a tool that can be called by the LLM. Let’s examine the key components:
-
Tool annotations: We’ve added annotations to provide metadata about our tools:
title
: A human-readable name for the tool in UI displaysreadOnlyHint
: Set toTrue
since these tools only read files without modifying anythingopenWorldHint
: Set toFalse
as these tools work with local files, not external systems
-
Descriptive docstrings: The docstrings are critical as they help the LLM understand when and how to use the tool. Including argument descriptions with the
Args:
section provides even more context. -
Error handling: We’ve wrapped the conversions in try/except blocks to capture and return any errors in a format the LLM can understand. This follows best practices from the MCP tools specification.
-
Path handling: We use
os.path.expanduser()
to safely expand any tilde characters in file paths, which is important for supporting paths like~/Documents/file.pdf
. -
Markitdown usage: The
MarkItDown
library provides a simple.convert()
method that handles various document formats and returns the content as text. The.text_content
property gives us plain text that can be easily passed to the LLM.
These tools are quite simple, but they follow a pattern you can extend for more complex functionality. For instance, you could add tools that:
- Extract specific sections from documents
- Convert documents to different formats
- Search for keywords within documents
- Calculate statistics about document content
Remember that tools should be focused and perform a single clear action. This makes them easier for the LLM to understand and use correctly.
To read more about tools in MCPs, refer to the official documentation.
3. Adding resources to the server
Resources in MCP allow your server to expose static or dynamic data that can be accessed by the host application. Unlike tools which are model-controlled, resources are application-controlled, meaning they’re typically provided to the model as context rather than being called by the model directly.
Let’s implement some resources for our document reader server:
# document_reader.py
@mcp.resource("file://document/pdf-example")
def provide_example_pdf():
"""Provide the content of an example PDF document.
This resource makes a sample PDF available to the model without requiring
the user to specify a path.
"""
try:
# Use an absolute path with the file:// schema
pdf_path = "file:///Users/bexgboost/Downloads/example.pdf"
# Convert the PDF to text using markitdown
return md.convert(pdf_path).text_content
except Exception as e:
return f"Error providing example PDF: {str(e)}"
@mcp.resource("file://document/recent/{filename}")
def provide_recent_document(filename: str):
"""Provide access to a recently used document.
This resource shows how to use path parameters to provide dynamic resources.
"""
try:
# Construct the path to the recent documents folder
recent_docs_folder = os.path.expanduser("~/Documents/Recent")
file_path = os.path.join(recent_docs_folder, filename)
# Validate the file exists
if not os.path.exists(file_path):
return f"File not found: {filename}"
# Convert to text using markitdown
return md.convert(file_path).text_content
except Exception as e:
return f"Error accessing document: {str(e)}"
Let’s examine the key components of these resources:
-
Resource URIs: Resources in MCP are identified by URI-like paths with a schema. In our examples:
file://document/pdf-example
is a static resource pathfile://document/recent/{filename}
uses a path parameter for dynamic resources
-
Path parameters: The second resource shows how to use path parameters (enclosed in curly braces) to create dynamic resources. The parameter
{filename}
is passed to the function as an argument. -
URI schemas: The
file://
schema indicates that these resources represent file contents. Other common schemas includehttp://
,data://
, or custom ones relevant to your application. -
Return values: Resources can return text content directly, which is what we’re doing by returning the text extracted from documents.
-
Error handling: Just like with tools, we include proper error handling to provide useful feedback when a resource can’t be accessed.
Resources aren’t limited to just files - databases, APIs, and other data sources can also be exposed as resources. For example:
# A sample - don't add this to our MCP server
@mcp.resource("db://customer/{customer_id}")
def get_customer_record(customer_id: str):
"""Retrieve a customer record from the database."""
try:
# In a real application, you would query your database
connection = database.connect("customer_database")
result = connection.query(f"SELECT * FROM customers WHERE id = {customer_id}")
return json.dumps(result.to_dict())
except Exception as e:
return f"Error retrieving customer data: {str(e)}"
@mcp.resource("api://weather/current/{location}")
def get_current_weather(location: str):
"""Get current weather for a specific location."""
try:
# In a real application, you would call a weather API
response = requests.get(f"https://weather-api.example/current?location={location}")
return response.text
except Exception as e:
return f"Error retrieving weather data: {str(e)}"
The resources we’ve defined here are just placeholder examples - in a real application, you might create resources that:
- Provide recent documents from a user’s history
- Give access to frequently needed reference materials
- Expose configuration settings
- Share information from connected systems
- Retrieve records from databases
- Fetch real-time data from external APIs
When designing resource URIs, it’s important to create a logical, hierarchical structure that makes the purpose of each resource clear. The path should indicate what kind of data the resource provides and how it relates to other resources in your system.
To read more about resources in MCPs, refer to the official documentation.
4. Adding prompts to the server
Prompts in MCP allow you to define reusable prompt templates that can be surfaced to users in the client application. Unlike tools (model-controlled) and resources (application-controlled), prompts are user-controlled, meant to be explicitly selected by users through the host UI by explicitly mentioning them.
Let’s implement a prompt for our document reader server:
from mcp.server.fastmcp.prompts import base
@mcp.prompt()
def debug_pdf_path(error: str) -> list[base.Message]:
"""Debug prompt for PDF issues.
This prompt helps diagnose issues when a PDF file cannot be read.
Args:
error: The error message encountered
"""
return [
base.Message(
role="user",
content=[
base.TextContent(
text=f"I'm trying to read a PDF file but encountered this error: {error}. "
f"How can I resolve this issue? Please provide step-by-step troubleshooting advice."
)
]
)
]
Let’s examine the key components of this prompt implementation:
-
Prompt definition: The prompt is defined using the
@mcp.prompt()
decorator, which registers it with the MCP server. -
Function parameters: The
error
parameter becomes an argument that users can provide when invoking the prompt. Parameters can be required or optional (with default values). -
Return structure: Prompts return a list of
base.Message
objects, which represent the conversation to be sent to the LLM. Each message has:- A
role
(usually “user” or “assistant”) content
containing one or more content items (typicallyTextContent
)
- A
-
Docstring: Like with tools and resources, a clear docstring helps explain the purpose of the prompt and documents its arguments.
This prompt would appear in the client application’s UI, allowing users to easily get help with PDF-related errors. For example, when encountering an error reading a file, they could select “Debug PDF path” from the UI and provide the error message to get troubleshooting advice.
When implementing prompts, remember that they’re meant to create reusable, standardized interactions that make common tasks easier for users. Well-designed prompts can significantly improve the usability of your MCP server by providing clear pathways for accomplishing specific goals.
To read more about prompt usage in MCPs, refer to the official documentation.
5. Debugging the MCP server
FastMCP comes with a built-in debugging tool called the MCP Inspector, which provides a clean UI for testing your server components without needing to connect to an MCP host application. This inspector is invaluable for validating your implementation before deploying it.
To start the debugger, use the mcp dev
command followed by your script name:
mcp dev document_reader.py
This will start your MCP server and open the Inspector interface in your browser. If it doesn’t open automatically, you can typically access it at http://127.0.0.1:6274
.
Once the Inspector loads, click the “Connect” button to establish a connection with your server. The interface will display tabs for exploring the three main components of your MCP server:
- Tools: Lists all available tools with their descriptions
- Resources: Shows all registered resources
- Prompts: Displays all defined prompts
In the Tools tab, you should see the two tools we created earlier: “Read PDF Document” and “Read Word Document”. You can click on any tool to see its details and test it by providing arguments. For example, clicking on the PDF tool will show an input field for the file path. Enter the full path to a PDF file on your machine, and the tool will execute, showing the extracted text content.
Similarly, in the Resources tab, you can test your resources by selecting them and providing any required path parameters. For our dynamic resource with the {filename}
parameter, you’ll be prompted to enter a filename to test the resource.
For prompts, the Inspector will display a form with fields for each prompt argument. You can fill in these fields to see how the prompt would be rendered before sending it to an LLM.
The Inspector is particularly useful for:
- Validating argument handling: Ensure your tools, resources, and prompts correctly handle different argument values, including edge cases
- Testing error handling: Intentionally provide invalid inputs to verify your error handling logic
- Checking content formatting: Make sure the text extraction and formatting work as expected
- Debugging path issues: Troubleshoot file path handling across different operating systems
In a real-world workflow, once you’ve validated your MCP server with the Inspector, you would add it to an MCP host like Claude Desktop or Cursor. Then, users could ask natural language questions like “Summarize the contents of my PDF at ~/Downloads/test.pdf” and the LLM would automatically identify the appropriate tool to use, extract the file path parameter, and provide the requested information.
For more details on the Inspector and MCP debugging techniques, you can refer to the official documentation at the MCP Inspector page and the Debugging guide.
6. Connecting to the MCP server locally
Now, the moment of truth - testing our server locally with a host application. For Claude Desktop, the setup is straightforward. In the same directory where your server script is, call the mcp install
command:
mcp install document_reader.py
Restart Claude Desktop and the server must be visible:
Let’s ask it to summarize our Word Document:
Our server is fully operation in Claude. Now, let’s add it to Cursor by pasting the following JSON configuration to ~/.cursor/mcp.json
file:
{
"mcpServers": {
..., // Other servers
"document-reader-mcp": {
"command": "uv",
"args": [
"--directory",
"/path/to/server/directory",
"run",
"document_reader.py"
]
}
}
}
Afterward restarting Cursor, the server must be visible in your settings:
Let’s test it on a PDF document in my working directory:
As you can see, I didn’t have to provide the file path to the PDF in my environment. The prompt itself was enough for Cursor to figure out that it needs to use the full path for the MCP tool to work correctly.
If you server is for personal use only, our work here is done. However, if you wish to make it public for external users, read on to the next section.
7. Deploying the MCP server
After successfully building and testing your MCP server locally, the next step is to deploy it for broader use. There are two primary approaches to MCP server deployment: local hosting using stdio transport and remote hosting using SSE transport.In this section, we’ll explore how to package our document reader server for distribution via PyPI.
Packaging your MCP server as a Python package allows others to easily install and use it with a simple command. Here’s how to structure and publish your MCP server:
- First, rename your existing
document_reader.py
file toserver.py
for consistency with the package structure, then create the necessary directory structure:
mcp-document-reader/
├── src/
│ └── document_reader/
│ ├── __init__.py
│ └── server.py
├── pyproject.toml
├── README.md
└── LICENSE
- In the
__init__.py
file, import and expose the main components of your server:
# src/document_reader/__init__.py
from .server import read_pdf, read_docx, mcp
- Define your package metadata in
pyproject.toml
(change the name to something unique as I’ve already taken the name below):
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "mcp-document-reader"
version = "0.1.0"
description = "MCP server for reading and extracting text from PDF and DOCX files"
readme = "README.md"
authors = [
{name = "Your Name", email = "your.email@example.com"}
]
license = {text = "MIT"}
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
dependencies = [
"mcp>=1.2.0",
"markitdown[all]",
]
requires-python = ">=3.9"
[project.scripts]
mcp-document-reader = "document_reader.server:main"
[tool.setuptools]
package-dir = {"" = "src"}
- Add a
main()
function to yourserver.py
file to make it executable:
# src/document_reader/server.py
def main():
mcp.run()
if __name__ == "__main__":
main()
- Build your package using the build tool:
uv pip install build
python -m build
This will generate distribution files in the dist/
directory.
- Create a PyPI account and upload your package:
Before uploading to PyPI, you’ll need to create an account at PyPI.org. Once registered, you must verify your email, add 2FA authentication and generate an API token in your settings. Then, you can use twine to securely upload your package. Twine is a utility for publishing Python packages to PyPI that provides better security through HTTPS and supports modern authentication methods.
uv pip install twine
When uploading to PyPI for the first time, you’ll be prompted for your PyPI credentials. For better security, consider using an API token instead of your password:
twine upload dist/*
Alternatively, you can store your credentials in a .pypirc
file in your home directory or use environment variables.
Once published, users can install your MCP server with:
uv add mcp-document-reader
And run it directly:
mcp-document-reader
For example, my server is now live at https://pypi.org/project/mcp-document-reader/.
To use the packaged server with Claude Desktop or other MCP clients, users can add it to their configuration file using uv:
"pypi-document-reader-mcp": {
"command": "uv",
"args": [
"--directory",
"/Users/bexgboost/miniforge3/lib/python3.12/site-packages/document_reader",
"run",
"server.py"
]
}
Note: For the server to be installed correctly in your hosts, you must provide the installation directory for it like I am doing above.
This deployment method is ideal for sharing your MCP server with others while maintaining the simplicity of local hosting. The server runs on the user’s machine, so there are no concerns about hosting costs or managing remote infrastructure.
For more detailed information about hosting options, including remote deployment using SSE transport, refer to Aravind Putrevu’s guide on hosting MCP servers.
Conclusion
FastMCP makes it easy to build custom tools that extend LLM capabilities for specific tasks. Our document reader example shows how to enable AI assistants to process PDF and DOCX files without complex code. This pattern works for many applications including web scraping, data analysis, and API integration. For web scraping in AI applications, Firecrawl provides a ready-to-use MCP server compatible with Claude Desktop and Cursor.
MCPs allow developers to create standardized extensions for language models with minimal effort. This tutorial covers the basics of building and deploying a simple MCP server using Python. For more details, check the official MCP documentation for complete reference materials. Visit the Firecrawl blog for practical tutorials on combining web scraping with AI and implementing these technologies in real projects.
On this page
Why Build an MCP Server?
How to Use Existing MCP Servers with Cursor or Claude Desktop
Step 1: Installing Node.js and UV Python
Step 2: Choose a server you can immediately start using
Step-by-step guide to building MCP servers with FastMCP
0. Installing FastMCP
1. Defining tools, resources and prompts
2. Adding tools to the server
3. Adding resources to the server
4. Adding prompts to the server
5. Debugging the MCP server
6. Connecting to the MCP server locally
7. Deploying the MCP server
Conclusion
About the Author

Bex is a Top 10 AI writer on Medium and a Kaggle Master with over 15k followers. He loves writing detailed guides, tutorials, and notebooks on complex data science and machine learning topics
More articles by Bex Tuychiev
The Best Open Source Frameworks For Building AI Agents in 2025
Discover the top open source frameworks for building powerful AI agents with advanced reasoning, multi-agent collaboration, and tool integration capabilities to transform your enterprise workflows.
Top 7 AI-Powered Web Scraping Solutions in 2025
Discover the most advanced AI web scraping tools that are revolutionizing data extraction with natural language processing and machine learning capabilities.
Building an Automated Price Tracking Tool
Learn how to build an automated price tracker in Python that monitors e-commerce prices and sends alerts when prices drop.
Web Scraping Automation: How to Run Scrapers on a Schedule
Learn how to automate web scraping in Python using free scheduling tools to run scrapers reliably in 2025.
Automated Data Collection - A Comprehensive Guide
A comprehensive guide to building robust automated data collection systems using modern tools and best practices.
Top 9 Browser Automation Tools for Web Testing and Scraping in 2025
Comprehensive comparison of the best browser automation frameworks including Selenium, Playwright, Puppeteer, and Cypress for web testing, data extraction, and workflow automation with implementation guides.
BeautifulSoup4 vs. Scrapy - A Comprehensive Comparison for Web Scraping in Python
A comprehensive comparison of BeautifulSoup4 and Scrapy to help you choose the right Python web scraping tool.
How to Build a Client Relationship Tree Visualization Tool in Python
Build an application that discovers and visualizes client relationships by scraping websites with Firecrawl and presenting the data in an interactive tree structure using Streamlit and PyVis.