Problem: You Can't Find an MCP Server That Does Exactly What You Need
The MCP ecosystem has dozens of pre-built servers, but they rarely match your exact internal APIs, data sources, or tooling. You need to expose your own Python functions — a database query, a file processor, an internal REST call — to Claude or another MCP client.
The official Python SDK makes this doable in ~100 lines. Most tutorials stop at "hello world." This one goes further.
You'll learn:
- How to register tools, resources, and prompts on a single MCP server
- How to handle typed inputs and return structured output
- How to connect your server to Claude Desktop and test it end-to-end
Time: 30 min | Difficulty: Intermediate
Why This Happens
MCP (Model Context Protocol) standardizes how AI models talk to external context providers. A server exposes three primitives:
- Tools — functions the model can call (e.g.,
search_database,send_email) - Resources — readable data the model can pull (e.g., a file, a live metric)
- Prompts — reusable prompt templates with arguments
Without a custom server, you're limited to what community servers already expose. Your internal systems stay invisible to the model.
What you need before starting:
- Python 3.11+
uvpackage manager (recommended) orpip- Claude Desktop installed and configured
Solution
Step 1: Create the Project and Install Dependencies
# Create and enter project directory
mkdir my-mcp-server && cd my-mcp-server
# Initialize with uv (fast, reproducible)
uv init --python 3.11
# Install the official MCP Python SDK
uv add mcp
Expected output:
Resolved 12 packages in 340ms
Installed 12 packages in 180ms
+ mcp==1.5.0
If it fails:
uv: command not found→ Install withcurl -LsSf https://astral.sh/uv/install.sh | shPython 3.11 not found→ Runuv python install 3.11first
Step 2: Scaffold the Server File
Create server.py in the project root:
from mcp.server.fastmcp import FastMCP
# FastMCP handles the protocol boilerplate — you just register handlers
app = FastMCP(name="my-server")
if __name__ == "__main__":
app.run()
Run it to confirm the SDK is wired up correctly:
uv run python server.py
Expected output:
Starting MCP server 'my-server' on stdio transport
Press Ctrl+C to stop. The server communicates over stdio — Claude Desktop spawns it as a subprocess and talks to it over stdin/stdout.
Step 3: Register a Tool
Tools are Python functions decorated with @app.tool(). The SDK reads the type hints and docstring to generate the JSON schema that the model sees.
from mcp.server.fastmcp import FastMCP
app = FastMCP(name="my-server")
@app.tool()
def add_numbers(a: float, b: float) -> float:
"""Add two numbers together and return the result."""
return a + b
@app.tool()
def search_notes(query: str, max_results: int = 5) -> list[dict]:
"""
Search internal notes by keyword.
Args:
query: The search term to look for in note titles and bodies.
max_results: Maximum number of results to return. Defaults to 5.
"""
# Replace with your actual data source
dummy_notes = [
{"id": 1, "title": "MCP Setup", "body": "Notes on configuring MCP servers"},
{"id": 2, "title": "Python Tips", "body": "Useful Python patterns for 2026"},
]
results = [n for n in dummy_notes if query.lower() in n["title"].lower() or query.lower() in n["body"].lower()]
return results[:max_results]
if __name__ == "__main__":
app.run()
Key points:
- Type hints are required. The SDK uses them to generate the input schema. Missing types = the model can't call the tool correctly.
- Docstrings become tool descriptions. Write them for the model, not just for humans. Be explicit about what each argument expects.
- Return type matters. Return
str,int,float,list, ordict. Anything else needs astr()cast.
Step 4: Register a Resource
Resources are URIs the model can read on demand — think of them as readable documents or live data endpoints.
@app.resource("notes://all")
def get_all_notes() -> str:
"""Returns all notes as formatted text."""
notes = [
{"id": 1, "title": "MCP Setup", "body": "Notes on configuring MCP servers"},
{"id": 2, "title": "Python Tips", "body": "Useful Python patterns for 2026"},
]
return "\n\n".join(f"# {n['title']}\n{n['body']}" for n in notes)
@app.resource("notes://{note_id}")
def get_note_by_id(note_id: int) -> str:
"""Returns a single note by its integer ID."""
notes = {
1: {"title": "MCP Setup", "body": "Notes on configuring MCP servers"},
2: {"title": "Python Tips", "body": "Useful Python patterns for 2026"},
}
note = notes.get(note_id)
if not note:
return f"Note {note_id} not found."
return f"# {note['title']}\n{note['body']}"
The {note_id} in the URI template is automatically extracted and passed as a function argument. The model can request notes://1 and your handler receives note_id=1.
Step 5: Register a Prompt Template
Prompts let you package reusable instructions — the model or the user can invoke them by name.
from mcp.server.fastmcp import FastMCP
from mcp.types import PromptMessage, TextContent
@app.prompt("summarize-note")
def summarize_note_prompt(note_id: int) -> list[PromptMessage]:
"""Generate a prompt asking the model to summarize a specific note."""
return [
PromptMessage(
role="user",
content=TextContent(
type="text",
text=f"Please read the note at notes://{note_id} and write a 3-bullet summary.",
),
)
]
Step 6: Connect to Claude Desktop
Open Claude Desktop's config file:
# macOS
open ~/Library/Application\ Support/Claude/claude_desktop_config.json
# Windows
notepad %APPDATA%\Claude\claude_desktop_config.json
Add your server under mcpServers:
{
"mcpServers": {
"my-server": {
"command": "uv",
"args": [
"run",
"--directory",
"/absolute/path/to/my-mcp-server",
"python",
"server.py"
]
}
}
}
Replace /absolute/path/to/my-mcp-server with your actual project path. Use pwd in the project directory to get it.
Restart Claude Desktop completely (Quit, not just close the window).
If it fails:
- Server not showing in Claude → Check the path is absolute, not relative
ModuleNotFoundError: mcp→ Confirmuv runresolves to the project's venv, not a global Python- Server crashes on startup → Run
uv run python server.pymanually to see the traceback
Verification
After restarting Claude Desktop, click the tools icon (🔧) in the chat input bar. You should see your server listed with its tools.
Test it by asking Claude directly:
Use the search_notes tool to find notes about "MCP"
You should see: Claude calls search_notes with query="MCP" and returns the matching notes.
To verify the resource is accessible:
Read the resource at notes://1 and summarize it
You should see: Claude fetches the resource content and summarizes it inline.
To check tool registration from the command line without Claude Desktop:
# List all registered tools via MCP inspector
uv run mcp dev server.py
This opens the MCP Inspector in your browser — a visual interface to call tools and read resources directly.
Adding Error Handling and Typed Returns
Production servers need structured errors. Raise ValueError for bad input — the SDK converts it to a proper MCP error response:
from pydantic import BaseModel
class NoteResult(BaseModel):
id: int
title: str
body: str
found: bool
@app.tool()
def get_note(note_id: int) -> NoteResult:
"""
Fetch a note by ID.
Args:
note_id: Integer ID of the note. Must be a positive integer.
"""
if note_id <= 0:
# ValueError becomes a structured MCP error — the model sees the message
raise ValueError(f"note_id must be positive, got {note_id}")
notes = {1: {"title": "MCP Setup", "body": "Notes on configuring MCP servers"}}
note = notes.get(note_id)
if not note:
return NoteResult(id=note_id, title="", body="", found=False)
return NoteResult(id=note_id, title=note["title"], body=note["body"], found=True)
Pydantic models as return types give the model a reliable schema to parse. This matters for tool chaining — when one tool's output feeds into another tool's input.
What You Learned
FastMCPhandles protocol boilerplate — you write plain Python functions- Type hints + docstrings are how the model understands what a tool does — treat them as part of the API contract
- Tools handle actions, resources handle readable data, prompts handle reusable instructions — use the right primitive for the job
- The MCP Inspector (
mcp dev) lets you test your server without needing Claude Desktop
Limitation: FastMCP's stdio transport is single-client. For serving multiple clients or remote connections, use the SSE transport: app.run(transport="sse", host="0.0.0.0", port=8000). This requires additional auth handling before exposing it over a network.
Tested on mcp 1.5.0, Python 3.11, uv 0.5.x, Claude Desktop 0.9.x, macOS Sequoia and Ubuntu 24.04