ModelRefs / Build a Custom MCP Server in Python
Build a Custom MCP Server in Python
Build a custom MCP server in Python with FastMCP: expose typed tools, resources, and prompts, test with the Inspector, and connect it to Claude.
New here? Read the primer on what tool calling is first, and if you want the model side, follow our tutorial on bridging Claude and a local model with MCP.
What you'll build
You will build a small support-desk MCP server. It exposes two tools (create and search tickets), one resource (read a ticket by id), and one prompt (triage a ticket).
That mix is deliberate. It shows all three MCP primitives, plus typed input validation and error handling, in about 50 lines of Python.
By the end you will have a server you can test in the Inspector, connect to Claude, and adapt to your own domain.
Prerequisites
Pin your versions. Stating what you tested on is good practice and an honest signal to readers.
- Python 3.10+.
- The official MCP SDK. Install with
pip install mcp(oruv add mcp). It ships with the FastMCP framework used below. - Node.js 18+ to run the MCP Inspector via
npx. - An MCP host for the final step, such as Claude Desktop or Claude Code.
The three MCP primitives
A server exposes three kinds of capability. Knowing which to reach for is most of good server design.
- Tools are actions with side effects, like creating a ticket or sending an email. A model calls them through tool calling.
- Resources are read-only data the host can load, like a document or a record. Think of them as GET requests.
- Prompts are reusable templates the host can surface, often as slash commands.
The rule of thumb: if it changes state, it is a tool. If it only reads, it is a resource. If it shapes a request, it is a prompt.
Step 1. Set up the project
Create a file called server.py and start a FastMCP server. The name you pass identifies the server to hosts.
# server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("support-desk")
# A tiny in-memory store so the example runs. Swap for a real database.
TICKETS: dict[str, dict] = {}
if __name__ == "__main__":
mcp.run() # stdio transport by default
That is a valid, running server with no capabilities yet. Next, give it something to do.
Step 2. Add a tool with a typed schema
Tools are Python functions decorated with @mcp.tool(). FastMCP reads the type hints and docstring to build the schema the model sees, so clear types and a clear description do real work.
Use Annotated with a Pydantic Field to add validation and per-parameter descriptions:
from typing import Annotated, Literal
from pydantic import Field
from uuid import uuid4
@mcp.tool()
def create_ticket(
title: Annotated[str, Field(description="Short summary of the issue", min_length=3)],
priority: Literal["low", "medium", "high"] = "medium",
) -> dict:
"""Create a support ticket. Returns the new ticket id and status."""
ticket_id = uuid4().hex[:8]
TICKETS[ticket_id] = {"title": title, "priority": priority, "status": "open"}
return {"id": ticket_id, "status": "open"}
@mcp.tool()
def search_tickets(query: str) -> list[dict]:
"""Search open tickets by keyword in the title."""
q = query.lower()
return [
{"id": tid, **t}
for tid, t in TICKETS.items()
if q in t["title"].lower()
]
Two design choices matter here. The Literal type limits priority to three valid values, so the model cannot invent a fourth. The docstrings become the tool descriptions, so write them for a new teammate, not a compiler.
Step 3. Add a resource
Resources expose read-only data at a URI. A templated URI captures a parameter, much like a route.
@mcp.resource("ticket://{ticket_id}")
def read_ticket(ticket_id: str) -> str:
"""Read a single ticket by its id."""
ticket = TICKETS.get(ticket_id)
if ticket is None:
return f"No ticket found with id {ticket_id}."
return f"[{ticket['priority']}] {ticket['title']} (status: {ticket['status']})"
The host can now load ticket://a1b2c3d4 as context without calling a tool. Reads stay separate from actions, which keeps your surface predictable.
Step 4. Add a prompt
Prompts are reusable templates. They let you ship a proven instruction with the server instead of asking every user to write their own.
@mcp.prompt()
def triage_ticket(ticket_text: str) -> str:
"""Triage a raw ticket into a category and a priority."""
return (
"Triage this support ticket. Reply with a category and a priority "
"(low, medium, or high), then one sentence of reasoning.\n\n"
f"Ticket:\n{ticket_text}"
)
A host can surface triage_ticket as a slash command. The value is consistency: everyone triages the same way, because the template lives with the tool.
Step 5. Handle errors
Tools fail. A model handles a clear error far better than a silent wrong answer, so validate inputs and raise on bad state.
@mcp.tool()
def close_ticket(ticket_id: str) -> dict:
"""Close an open ticket. Raises if the ticket does not exist."""
ticket = TICKETS.get(ticket_id)
if ticket is None:
raise ValueError(f"Ticket {ticket_id} does not exist.")
ticket["status"] = "closed"
return {"id": ticket_id, "status": "closed"}
FastMCP turns a raised exception into a structured tool error the model can read and react to. Return values for success, raise for failure, and keep both messages specific.
Step 6. Test with the MCP Inspector
Before wiring the server to a model, test it in isolation. The official MCP Inspector is a browser tool that lists your tools, resources, and prompts and lets you call them by hand.
# Launch the Inspector against your server.
npx @modelcontextprotocol/inspector python server.py
Call create_ticket, then search_tickets, then load a ticket:// resource. Watching the raw JSON-RPC exchange here is the fastest way to catch a bad schema or a confusing description before a model ever sees it.
Step 7. Connect it to Claude
Once the server passes the Inspector, register it with a host. The SDK can install it into Claude Desktop for you:
mcp install server.py
Or add it by hand to the host config, exactly as in our tutorial on bridging Claude and a local model with MCP:
{
"mcpServers": {
"support-desk": {
"command": "python",
"args": ["/absolute/path/to/server.py"]
}
}
}
Restart the host, and the model can create, search, read, and close tickets on request.
Step 8. Local vs. remote (stdio or Streamable HTTP)
The transport you choose depends on where the server runs. This is the main decision point for a real deployment.
| stdio | Streamable HTTP | |
|---|---|---|
| Runs as | Local subprocess of the host | A network service over HTTPS |
| Best for | Local development, single user | Remote, multi-user, cloud |
| Auth | Environment variables at startup | OAuth 2.1, per request |
Switching is a one-line change:
if __name__ == "__main__":
# Local: mcp.run()
mcp.run(transport="streamable-http", host="127.0.0.1", port=8000)
Remote servers accept connections from outside your machine, so they need real authentication and origin checks. Keep untrusted servers local.
Design checklist
A few habits separate a server a model uses well from one it fumbles.
Keep the tool set small and distinct, since overlapping tools confuse selection. Write descriptions for a person, not a parser. Type every parameter and constrain it where you can, using Literal or Pydantic validators. Return structured data rather than prose, so the model can act on it. And read the tool-calling common mistakes before you scale the surface.
Simple and strict beats broad and loose. Add capability only when a real task needs it.
Failure modes and risks
Most problems come from a vague contract or an unsafe action.
The common failures are ambiguous tool descriptions (the model picks the wrong tool), unvalidated inputs (bad arguments reach your code), and overexposed actions (a destructive tool with no guard). On remote servers, tool poisoning is a real threat, where a malicious server hides instructions in a tool description to steer the model.
Treat every argument as untrusted, gate destructive actions, and secure remote servers with authentication. The evidence-first posture applies here too: test on realistic cases before you trust it.
Sources
- Model Context Protocol, Python SDK documentation (official) — FastMCP tool, resource, and prompt decorators, typed parameters, and the stdio and Streamable HTTP transports.
The MCP Inspector is referenced by its official command, npx @modelcontextprotocol/inspector.
Frequently asked questions
What are the three MCP primitives?
Tools (actions with side effects), resources (read-only data), and prompts (reusable templates).
How do I test an MCP server?
Use the official MCP Inspector: npx @modelcontextprotocol/inspector python server.py