← Back to list

The MCP Setup That Actually Makes Sense, A Practitioner’s Guide to FastMCP

The Model Context Protocol docs will show you how to connect a Python function to an LLM. What they won’t tell you is how much boilerplate…

Ozzmanmuhammad · 2026-06-10 12:15 · 0 claps · 4.9 min read
#mcp-server #mcps #fastmcp
Open on Medium ↗
Wiki topics: LLM · Large Language Models AGT · AI Agents

The MCP Setup That Actually Makes Sense, A Practitioner’s Guide to FastMCP

The Model Context Protocol docs will show you how to connect a Python function to an LLM. What they won’t tell you is how much boilerplate stands between you and a working tool.

I found this out the hard way. (We’ll call it a “learning experience” and move on.)

This post is about FastMCP a wrapper that strips out that boilerplate. But more importantly, it’s about understanding why the raw SDK makes you write so much, and why FastMCP’s approach actually makes sense once you dig into it.

First: Why MCP at all?

The Model Context Protocol is an open standard for giving LLMs access to tools querying databases, reading files, hitting APIs. The key idea: build one MCP server, and any MCP-compatible client Claude Desktop, custom agents, whatever can use it.

One bridge. Many clients. No rewriting integrations for every platform.

That’s the promise. The raw SDK delivers on it, but not elegantly.

The raw SDK problem

Here’s what connecting a simple Python function to an LLM actually involves with the official SDK:

  • Manually defining JSON schemas for every function
  • Setting up transport configuration yourself
  • Carefully mapping inputs so the LLM doesn’t misinterpret types

For complex, custom setups? Fine. For exposing a single function? We’re writing 80 lines to do what should take 10.

What FastMCP actually does

FastMCP sits on top of the official SDK. It doesn’t replace it — it just removes the parts that don’t need to be our problem.

Three things it handles:

Schema generation Write a normal Python function with type hints and a docstring. FastMCP reads both and generates the JSON schema automatically. Zero schema code from us.

Transport STDIO for local setups like Claude Desktop. HTTP for remote or scalable deployments. Both work without configuration. I genuinely expected this to be more complicated than it was.

Input validation via Pydantic This one matters more than it looks. Without validation, an LLM can silently pass the wrong data type into your function and fail in ways that are annoying to debug. FastMCP validates before the input ever reaches our logic.

Building blocks: Tools, Resources, and Prompts

FastMCP covers all three core MCP components. Let’s build each one so we can see exactly what FastMCP is doing for us.

1. Tools (Functions the LLM can call)

The most common use case. We define a function, decorate it, and FastMCP handles everything else.

pip install fastmcp
from fastmcp import FastMCP
import math

mcp = FastMCP("Geometry_Tool")

@mcp.tool()
def calculate_circle_area(radius: float) -> str:
   """
   Calculates the area of a circle given its radius.
   Args:
     radius: The radius of the circle in meters.
   """

   area = math.pi * (radius ** 2)
   return f"The area is {area:.2f} square meters."

Now let’s make it more realistic. Here’s a multi-tool server that handles a notes app the kind of thing you’d actually build:

from fastmcp import FastMCP
from datetime import datetime
import json, os

mcp = FastMCP("Notes_Manager")

NOTES_FILE = "notes.json"

def load_notes() -> dict:
 if not os.path.exists(NOTES_FILE):
 return {}
 with open(NOTES_FILE, “r”) as f:
 return json.load(f)

def save_notes(notes: dict):
 with open(NOTES_FILE, “w”) as f:
 json.dump(notes, f, indent=2)

@mcp.tool()
def add_note(title: str, content: str) -> str:
   """
   Saves a new note with a title and content.
   Args:
   title: The title of the note.
   content: The body content of the note.
   """

   notes = load_notes()
   notes[title] = {
   "content": content,
   "created_at": datetime.now().isoformat()
   }
   save_notes(notes)
   return f"Note '{title}' saved successfully."

@mcp.tool()
def get_note(title: str) -> str:
   """
   Retrieves a note by its title.
   Args:
   title: The title of the note to retrieve.
   """

   notes = load_notes()
   if title not in notes:
   return f"No note found with title '{title}'."
   note = notes[title]
   return f"Title: {title}\nCreated: {note['created_at']}\n\n{note['content']}"

@mcp.tool()
def list_notes() -> str:
   """Returns a list of all saved note titles."""
   notes = load_notes()
   if not notes:
   return "No notes saved yet."
   return "\n".join(f"- {title}" for title in notes.keys())

Notice what we didn't write across three tools: not a single line of schema definition. FastMCP inferred all of it.

2. Resources (Data the LLM can read)

Resources are different from tools. A tool does something. A resource exposes something a file, a config value, a live data feed. The LLM reads it, doesn’t execute it.

Here’s how we expose a system config as a resource:

@mcp.resource("config://app-settings")
def get_app_settings() -> str:
 """Returns the current application configuration."""
 settings = {
 "version": "1.0.0",
 "environment": "production",
 "max_retries": 3,
 "timeout_seconds": 30
 }

 return json.dumps(settings, indent=2)

We can also expose dynamic resources data that changes at read time. Here’s one that reads a log file:

@mcp.resource("logs://latest")
def get_latest_logs() -> str:
 """Returns the last 50 lines of the application log."""
 log_path = "app.log"
 if not os.path.exists(log_path):
   return "No logs found."
 with open(log_path, "r") as f:
   lines = f.readlines()
 return "".join(lines[-50:])

The LLM can now read live log data without us writing any transport or parsing logic.

3. Prompts (Pre-built interaction templates)

Prompts are reusable templates that shape how users interact with the LLM through our server. Think of them as saved conversation starters with context already baked in.

Here’s a practical one a debugging prompt that primes the LLM with the right context:

from fastmcp import FastMCP
from mcp.types import PromptMessage, TextContent 

mcp = FastMCP("Debug_Assistant")

@mcp.prompt()
def debug_assistant(error_message: str, code_snippet: str) -> list[PromptMessage]:
 """
 Primes the LLM to act as a debugging assistant with specific context.Args:
 error_message: The error we’re trying to fix.
 code_snippet: The relevant code block.
 """

 prompt_text = (
 f"We’re debugging a Python error. Here’s the context:\n\n"
 f"Error: {error_message}\n\n"
 f"Code:\n{code_snippet}\n\n"
 f"Walk us through what’s likely causing this and the cleanest fix."
 )

return [
 PromptMessage(
     role="user",
     content=TextContent(
       type="text",
       text=prompt_text
     )
  )
]

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

Instead of every user typing out context from scratch, they call this prompt and it arrives pre-loaded. Useful when we know exactly what information the LLM needs to be helpful.

When does FastMCP make sense?

Use it when:

  • You’re building quickly and don’t want the SDK fighting you
  • You need reliable input validation without writing it yourself
  • You want the same server working across multiple MCP clients

If you need very precise, low-level protocol control stick with the raw SDK. For everything else, FastMCP gets us there faster without sacrificing reliability.

from fastmcp import FastMCP
from mcp.types import PromptMessage, TextContent
from datetime import datetime
import json, os, math

mcp = FastMCP("Full_Example_Server")

# - - Tool - -
@mcp.tool()
def calculate_circle_area(radius: float) -> str:
     """Calculates the area of a circle given its radius."""
     area = math.pi * (radius ** 2)
     return f"The area is {area:.2f} square meters."

# - - Resource - -
@mcp.resource("config://app-settings")
def get_app_settings() -> str:
     """Returns the current application configuration."""
     return json.dumps({"version": "1.0.0", "environment": "production"}, indent=2)

# - - Prompt - -
@mcp.prompt()
def debug_assistant(error_message: str, code_snippet: str) -> list[PromptMessage]:
     """Primes the LLM as a debugging assistant."""
     return [
         PromptMessage(
             role="user",
             content=TextContent(
                 type="text",
                 text=f"Error: {error_message}\n\nCode:\n{code_snippet}\n\nWhat's causing this and what's the fix?"
             )
         )
     ]

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

One file. Three components. Zero schema code.

(Yes, I condensed all of this so we didn’t have to scroll back up.)

Docs at gofastmcp.com. The source code is worth a read too — it’s compact and understanding it clarifies a lot about how MCP works at the protocol level.

This covers the foundation. But there’s a lot we didn’t touch composing multiple MCP servers together, authentication on HTTP transport, and how FastMCP handles errors when an LLM passes something unexpected.

We’ll get into all of that. For now, get a server running. The interesting questions start once something is actually working.


메타데이터
post_id
e8cee77f4035
slug
the-mcp-setup-that-actually-makes-sense-a-practitioners-guide-to-fastmcp-e8cee77f4035
url
https://medium.com/@ozzmanmuhammad/the-mcp-setup-that-actually-makes-sense-a-practitioners-guide-to-fastmcp-e8cee77f4035
canonical_url
https://medium.com/@ozzmanmuhammad/the-mcp-setup-that-actually-makes-sense-a-practitioners-guide-to-fastmcp-e8cee77f4035
author_url
https://medium.com/@ozzmanmuhammad
status
ok
fetched_at
2026-06-11 06:59:45