Day 7 - Launch Week III.April 14th to 20th

April 13, 2025

•

Bex Tuychiev imageBex Tuychiev

FastMCP Tutorial: Building MCP Servers in Python From Scratch

FastMCP Tutorial: Building MCP Servers in Python From Scratch image

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.

MCP.so website showing a directory of available MCP servers for AI tools and LLMs

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

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:

Cursor IDE settings panel showing the MCP servers configuration with Firecrawl-MCP enabled

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 and read_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:

  1. Tool annotations: We’ve added annotations to provide metadata about our tools:

    • title: A human-readable name for the tool in UI displays
    • readOnlyHint: Set to True since these tools only read files without modifying anything
    • openWorldHint: Set to False as these tools work with local files, not external systems
  2. 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.

  3. 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.

  4. 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.

  5. 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:

  1. 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 path
    • file://document/recent/{filename} uses a path parameter for dynamic resources
  2. 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.

  3. URI schemas: The file:// schema indicates that these resources represent file contents. Other common schemas include http://, data://, or custom ones relevant to your application.

  4. Return values: Resources can return text content directly, which is what we’re doing by returning the text extracted from documents.

  5. 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:

  1. Prompt definition: The prompt is defined using the @mcp.prompt() decorator, which registers it with the MCP server.

  2. 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).

  3. 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 (typically TextContent)
  4. 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.

MCP Inspector interface showing tools, resources, and prompts tabs for debugging and testing MCP server components

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.

MCP Inspector interface showing available document reader tools with read_pdf and read_docx functions for testing PDF and Word document extraction capabilities

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.

MCP Inspector resources tab showing available document resources with dynamic filename parameters for testing document extraction capabilities

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:

  1. Validating argument handling: Ensure your tools, resources, and prompts correctly handle different argument values, including edge cases
  2. Testing error handling: Intentionally provide invalid inputs to verify your error handling logic
  3. Checking content formatting: Make sure the text extraction and formatting work as expected
  4. 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:

Claude Desktop interface showing the document reader MCP server successfully installed and available in the AI assistant's tools panel

Let’s ask it to summarize our Word Document:

Claude AI interface demonstrating document reader MCP server extracting and summarizing content from a 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:

Cursor IDE settings panel showing the document-reader-mcp server successfully configured and enabled in the MCP servers list

Let’s test it on a PDF document in my working directory:

Cursor IDE interface showing the document-reader MCP server successfully extracting and analyzing content from a PDF file with AI-generated response

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:

  1. First, rename your existing document_reader.py file to server.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
  1. 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
  1. 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"}
  1. Add a main() function to your server.py file to make it executable:
# src/document_reader/server.py
def main():
    mcp.run()

if __name__ == "__main__":
    main()
  1. Build your package using the build tool:
uv pip install build
python -m build

This will generate distribution files in the dist/ directory.

  1. 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.

Ready to Build?

Start scraping web data for your AI apps today.
No credit card needed.

About the Author

Bex Tuychiev image
Bex Tuychiev@bextuychiev

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.