How to Build a Custom MCP Server in Python With FastMCP (and Add It to Claude Desktop)

How to build a custom MCP server in Python with FastMCP and add it to Claude Desktop

Claude is good at reasoning about your business. It just can't see your business. The invoices sit in a CSV on someone's laptop, the stock list lives in a script nobody else understands, and every question starts with copy and paste. A custom MCP server fixes that. You write a few Python functions, and Claude Desktop can call them as tools. In this tutorial you'll build a small invoice-tracker server with FastMCP and plug it into Claude Desktop, start to finish.

Quick answer: Install FastMCP with uv add fastmcp (or pip install fastmcp), create a FastMCP("Name") object, put @mcp.tool on each Python function you want Claude to call, and end the file with mcp.run(). Then run fastmcp install claude-desktop server.py, fully quit and reopen Claude Desktop, and your tools show up in the chat.

What You'll Build

By the end you'll have:

  • ✓ A Python MCP server with three tools: list unpaid invoices, total what a client has been billed, and add a new invoice
  • ✓ A quick local test that proves the tools work before Claude ever touches them
  • ✓ The server registered in Claude Desktop, both the one-command way and the manual JSON way
  • ✓ A short troubleshooting checklist for the errors that trip most people up

When I build these for small businesses, I start with exactly this shape: a handful of narrow tools over data the owner already keeps. A CSV is the simplest stand-in. Once it works, swapping the CSV for a database, an accounting API or a Google Sheet is a change inside one function. If you'd rather not write code at all, Claude's built-in connectors might be enough. My Claude and Google Sheets tutorial covers that route.

Prerequisites

  • Claude Desktop on macOS or Windows. Local MCP servers run on your own computer, so the web version of Claude can't launch them.
  • Python 3.10 or newer. That's the floor the official MCP Python SDK sets, and FastMCP builds on it.
  • uv, the Python package manager. The FastMCP docs recommend it, and the Claude Desktop install command depends on it being on your PATH. On macOS, FastMCP suggests brew install uv so Claude Desktop can find it.
  • A terminal and a text editor. Nothing else.

Step 1: Create the Project and Install FastMCP

Open a terminal and run:

uv init invoice-mcp
cd invoice-mcp
uv add fastmcp
uv run fastmcp version

The last command prints the FastMCP and MCP versions, which confirms the install worked. If you prefer plain pip, pip install fastmcp inside a virtual environment does the same job, but stick with uv for this tutorial because the Claude Desktop steps assume it.

One naming trap to know about: older tutorials import from mcp.server.fastmcp import FastMCP. That's FastMCP 1.0, which shipped inside version 1 of the official MCP SDK. The standalone FastMCP package you just installed uses from fastmcp import FastMCP, and that's what the code below uses.

Step 2: Add Some Sample Data

Create a file called invoices.csv in the invoice-mcp folder:

invoice_id,client,amount,due_date,status
INV-001,Sharma Traders,12000,2026-10-15,paid
INV-002,Blue Lotus Cafe,4500,2026-10-30,unpaid
INV-003,Sharma Traders,8000,2026-11-20,unpaid
INV-004,Green Leaf Studio,6200,2026-11-05,unpaid

These rows are made-up test data. Use your own once everything works.

Step 3: Write the Server

Create invoice_server.py next to the CSV and paste this in:

import csv
import logging
from datetime import date
from pathlib import Path

from fastmcp import FastMCP

# logging writes to stderr, which is safe for a stdio server
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

DATA_FILE = Path(__file__).parent / "invoices.csv"
FIELDS = ["invoice_id", "client", "amount", "due_date", "status"]

mcp = FastMCP("Invoice Tracker")


def read_invoices() -> list[dict]:
    with DATA_FILE.open(newline="") as f:
        return list(csv.DictReader(f))


@mcp.tool
def list_unpaid_invoices() -> list[dict]:
    """Return every invoice that isn't marked paid, oldest due date first."""
    unpaid = [r for r in read_invoices() if r["status"].lower() != "paid"]
    return sorted(unpaid, key=lambda r: r["due_date"])


@mcp.tool
def total_billed(client: str) -> float:
    """Return the total amount billed to one client (case-insensitive)."""
    rows = [r for r in read_invoices() if r["client"].lower() == client.lower()]
    return round(sum(float(r["amount"]) for r in rows), 2)


@mcp.tool
def add_invoice(client: str, amount: float, due_date: str) -> str:
    """Add a new unpaid invoice. due_date must be in YYYY-MM-DD format."""
    date.fromisoformat(due_date)  # fails loudly on a bad date
    rows = read_invoices()
    new_id = f"INV-{len(rows) + 1:03d}"
    with DATA_FILE.open("a", newline="") as f:
        csv.DictWriter(f, fieldnames=FIELDS).writerow({
            "invoice_id": new_id, "client": client, "amount": amount,
            "due_date": due_date, "status": "unpaid",
        })
    logger.info("Added %s for %s", new_id, client)
    return f"Created {new_id} for {client}: {amount} due {due_date}"


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

A few things are doing the real work here:

  • @mcp.tool turns a normal function into a tool. FastMCP reads the type hints to build the input schema and uses the docstring as the description Claude sees. Write docstrings for Claude, not for yourself: say when to use the tool and what format the inputs need.
  • Path(__file__).parent finds the CSV relative to the script, not the current folder. Claude Desktop starts your server from its own working directory, so a bare "invoices.csv" often points at the wrong place.
  • No print(). The official MCP docs are blunt about this: a stdio server must never write to stdout, because stdout carries the JSON-RPC messages between Claude and your server. The logging module writes to stderr, so use it instead.
  • mcp.run() with no arguments uses the stdio transport, which is what Claude Desktop expects.

Step 4: Test It Before Claude Sees It

Debugging inside Claude Desktop is slow, so I always check the tools from the terminal first. Start the server over HTTP:

uv run fastmcp run invoice_server.py:mcp --transport http --port 8000

Leave that running, open a second terminal in the same folder, and save this as test_client.py:

import asyncio
from fastmcp import Client

client = Client("http://localhost:8000/mcp")

async def main():
    async with client:
        tools = await client.list_tools()
        print([t.name for t in tools])
        result = await client.call_tool("list_unpaid_invoices", {})
        print(result)

asyncio.run(main())

Run it with uv run python test_client.py. You should see the three tool names, then the unpaid invoices sorted by due date. Here a print() is fine, because this is the client, not the server. Stop the HTTP server with Ctrl+C when you're done. Note that fastmcp run doesn't execute your if __name__ == "__main__": block, so the HTTP flags here don't change how Claude Desktop will run the file later.

Step 5: Add the Server to Claude Desktop (One Command)

From the project folder, run:

uv run fastmcp install claude-desktop invoice_server.py --name "Invoice Tracker"

This writes the server entry into Claude Desktop's config file for you and sets up how its dependencies get installed. It needs FastMCP 2.10.3 or newer. A few flags worth knowing:

  • --with pandas adds an extra package the server needs (repeat it for more).
  • --env API_KEY=your-key or --env-file .env passes secrets in. Claude Desktop runs servers in an isolated environment, so variables from your shell don't carry over.
  • --python 3.12 pins the Python version.

Now fully quit Claude Desktop (quit from the menu, don't just close the window) and open it again.

Step 6: Or Edit the Config File by Hand

I like knowing exactly what's in the config, so on client machines I often write it myself. In Claude Desktop, open Settings, go to the Developer tab and click Edit Config. That opens (and creates, if needed) claude_desktop_config.json:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json

Add your server under mcpServers:

{
  "mcpServers": {
    "invoice-tracker": {
      "command": "uv",
      "args": [
        "--directory",
        "/ABSOLUTE/PATH/TO/invoice-mcp",
        "run",
        "invoice_server.py"
      ]
    }
  }
}

Replace the path with the real absolute path to your project. Run pwd on macOS or cd in Windows Command Prompt inside the folder to get it. On Windows, write the path with double backslashes, like C:\\Users\\you\\invoice-mcp. If Claude can't start the server, put the full path to uv in "command" (find it with which uv on macOS or where uv on Windows). Save, then fully quit and restart Claude Desktop.

Step 7: Use Your Tools in a Chat

In a new chat, click the button at the bottom-left of the message box (labelled "Add files, connectors, and more"), open Connectors, and check that Invoice Tracker is listed with its three tools. Then try prompts like these:

  • "Which invoices are still unpaid? Put the overdue ones first."
  • "How much have we billed Sharma Traders in total?"
  • "Add an invoice for Blue Lotus Cafe, 3,000, due 2026-12-15."

Each time, expand the tool call in the chat and check which tool Claude picked and what arguments it sent. That matters most for add_invoice, since it writes to your file. If Claude picks the wrong tool, the fix is usually a clearer docstring.

Troubleshooting

The server doesn't appear at all. Check the JSON first: a missing comma or bracket silently breaks the whole file. Then confirm you fully quit Claude Desktop rather than closing the window.

"Server disconnected" or the tools vanish right away. Read the logs. On macOS they're in ~/Library/Logs/Claude, on Windows in %APPDATA%\Claude\logs. mcp.log has connection errors, and mcp-server-invoice-tracker.log has your server's own stderr output, including Python tracebacks. On macOS, tail -n 20 -f ~/Library/Logs/Claude/mcp*.log follows them live.

uv not found. Claude Desktop doesn't always inherit your shell's PATH. Use the full path from which uv in the config, or on macOS reinstall uv with Homebrew.

FileNotFoundError for invoices.csv. You're using a relative path. Keep the Path(__file__).parent pattern from Step 3.

Strange JSON errors after you add a debug line. Somebody used print() in the server. Swap it for logger.info().

A secret works in the terminal but not in Claude. Environment variables from your shell don't reach the server. Pass them with --env or add an "env" object to the server's config entry.

Where to Take It Next

Once the pattern clicks, the next steps are small. Replace the CSV reads with calls to your real system. Keep read tools and write tools separate so the risky ones are easy to spot. And pair the server with a Claude Skill that tells Claude how your business wants invoices chased. My guide to building a Claude Skill step by step shows how to write one. If you work in Claude Code too, Claude Code hooks are a nice companion for keeping this kind of project tidy.

Frequently Asked Questions

What's the difference between FastMCP and the official MCP Python SDK?

FastMCP is a separate, higher-level framework for building MCP servers and clients. FastMCP 1.0 was bundled into version 1 of the official SDK as mcp.server.fastmcp. Today the standalone package is imported with from fastmcp import FastMCP, while the official SDK's own tutorial uses its MCPServer class.

Can I use a custom MCP server with Claude on the web?

Not a local stdio server like this one. Claude Desktop launches it as a process on your computer. To use a server from the web, you'd run it over HTTP on a public host and add it as a remote connector, which is a different setup.

Do I have to use uv?

No, but it's the easy path. The fastmcp install claude-desktop command relies on uv, and the official docs use it in their Claude Desktop config. You can point "command" at a Python interpreter instead if you manage the environment yourself.

Is it safe to give Claude write access to my files?

Your server can only do what its tools allow, so the risk sits in how you design them. Keep write tools narrow (add one invoice, not "run any SQL"), validate inputs like the date check above, and keep a backup of the data while you test.

Why don't my tools show up after I edit the config?

Claude Desktop only reads the config at startup. Quit it completely, reopen it, and check the logs if the server still doesn't connect.

Wrapping Up

A custom MCP server sounds like a big project, but the working core here is about 50 lines of Python. Write narrow tools with clear docstrings, test them from the terminal, register them with one command, and restart Claude Desktop properly. That's the whole loop, and it's the same loop whether the data behind it is a CSV or a full accounting system.

Want this built for your business?

I'm Basant, an AI automation consultant. Book a call: https://topmate.io/basant_narayan_singh

See more of my work at singhbasant.com.

NextGen Digital... Welcome to WhatsApp chat
Howdy! How can we help you today?
Type here...