Build an MCP Server from Scratch: Python FastMCP Tutorial
TL;DR — Build MCP server from scratch with Python FastMCP. DigitalOcean: "MCP server in Python uses mcp package. Pin mcp>=1.27,<2. FastMCP registers tools with @mcp.tool(), runs over stdio. Three primitives: tools, resources, prompts. On stdio, log to stderr — never print() to stdout or you break JSON-RPC." MCP Docs: "FastMCP uses Python type hints and docstrings to auto-generate tool definitions. Initialize, implement tools with @mcp.tool(), run with mcp.run(transport=stdio)." DeepWiki: "MCPServer provides decorators for three primitives: @mcp.tool() with ToolManager, @mcp.resource() with ResourceManager, @mcp.prompt() with PromptManager. Supports stdio and Streamable HTTP transports." Pyrastra: "Write ordinary Python, add decorators, get MCP server. Start with STDIO for Claude Desktop, move to Streamable HTTP for remote. FastMCPToolset connects Pydantic AI agents." DEV Community: "Run mcp dev server.py for MCP Inspector at localhost:5173. FastMCP handles JSON-RPC plumbing. Docstring is what AI reads to decide tool calls." Learn more with MCP guide, build MCP client, Claude Desktop MCP, and MCP transports.
DigitalOcean provides the quick start: "An MCP server in Python exposes tools, resources, and prompts to AI hosts through the Model Context Protocol. You write a small Python program. Cursor, Claude Desktop, or another MCP host starts the program and calls your functions when the model needs live data."
Pyrastra explains the FastMCP approach: "You write ordinary Python, add a few decorators, and get a Python MCP server that can expose tools, read-only resources, and reusable prompts. You can start with a local STDIO server for Claude Desktop or Cursor, then move the same server to Streamable HTTP when you need a remote endpoint."
MCP Server Architecture
import FastMCP"] Init["mcp = FastMCP('my-server')
name, title, description,
instructions, version"] end subgraph Primitives["Register Primitives"] Tool["@mcp.tool()
async def get_data(query: str)
-> str
auto-generates schema
from type hints"] Resource["@mcp.resource('docs://x')
def get_resource() -> dict
read-only data
URI-based access"] Prompt["@mcp.prompt()
def review_prompt(code: str)
-> str
reusable templates"] end subgraph Transport["Transport"] Stdio["stdio (default)
mcp.run(transport='stdio')
local, single client
Claude Desktop, Cursor"] HTTP["Streamable HTTP
mcp.run(transport='streamable-http')
remote, many clients
OAuth auth"] end subgraph Testing["Testing"] Inspector["MCP Inspector
mcp dev server.py
localhost:5173
call tools, verify responses"] Claude["Claude Desktop
claude_desktop_config.json
mcpServers config"] end Setup --> Primitives Primitives --> Transport Transport --> Testing Stdio --> Inspector Stdio --> Claude HTTP --> Inspector
Primitive Comparison
| Primitive | Decorator | Manager | Purpose | Example |
|---|---|---|---|---|
| Tools | @mcp.tool() |
ToolManager | Executable functions AI can invoke | get_alerts(state: str) -> str |
| Resources | @mcp.resource() |
ResourceManager | Read-only data via URI | docs://release-checklist |
| Prompts | @mcp.prompt() |
PromptManager | Reusable templates | review_prompt(code: str) |
Transport Comparison
| Transport | Use Case | Clients | Auth | Run Command |
|---|---|---|---|---|
| stdio | Local (Claude Desktop, Cursor) | Single | Environment credentials | mcp.run(transport='stdio') |
| Streamable HTTP | Remote, production | Many | OAuth, bearer tokens | mcp.run(transport='streamable-http') |
Implementation
from dataclasses import dataclass
from typing import Optional
from enum import Enum
class TransportType(Enum):
STDIO = "stdio"
STREAMABLE_HTTP = "streamable_http"
@dataclass
class MCPServerBuilder:
"""Complete MCP server implementation guide."""
def get_install(self) -> str:
"""Installation instructions."""
return (
"# Create virtual environment\n"
"python -m venv .venv\n"
"source .venv/bin/activate\n"
"\n"
"# Install MCP Python SDK\n"
"# Pin below 2.x while v2 is in alpha\n"
"pip install 'mcp>=1.27,<2'\n"
"\n"
"# Optional: install httpx for API calls\n"
"pip install httpx"
)
def get_basic_server(self) -> str:
"""Basic MCP server with one tool."""
return (
"from mcp.server.fastmcp import FastMCP\n"
"\n"
"# Initialize FastMCP server\n"
"mcp = FastMCP(\n"
" 'my-server',\n"
" instructions=(\n"
" 'This server provides tools '\n"
" 'for data access. '\n"
" 'Use get_data to fetch records.'\n"
" ),\n"
")\n"
"\n"
"@mcp.tool()\n"
"async def get_data(query: str) -> str:\n"
" '''Fetch data for a query.\n"
"\n"
" Args:\n"
" query: The search query\n"
" '''\n"
" # Tool implementation\n"
" return f'Results for: {query}'\n"
"\n"
"if __name__ == '__main__':\n"
" mcp.run(transport='stdio')"
)
def get_full_server(self) -> str:
"""Full MCP server with tools, resources, and prompts."""
return (
"import sys\n"
"import httpx\n"
"from mcp.server.fastmcp import FastMCP\n"
"\n"
"mcp = FastMCP(\n"
" 'github-tools',\n"
" instructions=(\n"
" 'This server provides tools to '\n"
" 'interact with the GitHub API. '\n"
" 'Use get_repo_info to fetch '\n"
" 'repository metadata. '\n"
" 'Use list_open_issues to retrieve '\n"
" 'open issues for a repository.'\n"
" ),\n"
")\n"
"\n"
"# --- TOOLS ---\n"
"\n"
"@mcp.tool()\n"
"async def get_repo_info(\n"
" owner: str, repo: str\n"
") -> dict:\n"
" '''Fetch metadata for a GitHub repo.\n"
"\n"
" Args:\n"
" owner: GitHub username or org\n"
" repo: Repository name\n"
" '''\n"
" async with httpx.AsyncClient() as client:\n"
" resp = await client.get(\n"
" f'https://api.github.com'\n"
" f'/repos/{owner}/{repo}',\n"
" timeout=10.0,\n"
" )\n"
" resp.raise_for_status()\n"
" data = resp.json()\n"
" return {\n"
" 'full_name': data['full_name'],\n"
" 'stars': data['stargazers_count'],\n"
" 'forks': data['forks_count'],\n"
" 'open_issues':\n"
" data['open_issues_count'],\n"
" 'url': data['html_url'],\n"
" }\n"
"\n"
"@mcp.tool()\n"
"async def list_open_issues(\n"
" owner: str, repo: str,\n"
" limit: int = 10\n"
") -> list[dict]:\n"
" '''List open issues for a repo.\n"
"\n"
" Args:\n"
" owner: GitHub username or org\n"
" repo: Repository name\n"
" limit: Max issues to return\n"
" '''\n"
" async with httpx.AsyncClient() as client:\n"
" resp = await client.get(\n"
" f'https://api.github.com'\n"
" f'/repos/{owner}/{repo}'\n"
" f'/issues?state=open'\n"
" f'&per_page={limit}',\n"
" timeout=10.0,\n"
" )\n"
" resp.raise_for_status()\n"
" return [\n"
" {'number': i['number'],\n"
" 'title': i['title'],\n"
" 'url': i['html_url']}\n"
" for i in resp.json()\n"
" ]\n"
"\n"
"# --- RESOURCES ---\n"
"\n"
"@mcp.resource('config://github-token')\n"
"def token_status() -> dict:\n"
" '''Check if GitHub token is set.'''\n"
" import os\n"
" token = os.environ.get(\n"
" 'GITHUB_TOKEN', '')\n"
" return {\n"
" 'configured': bool(token),\n"
" 'type': 'pat' if token else 'none',\n"
" }\n"
"\n"
"@mcp.resource('docs://{owner}/{repo}/readme')\n"
"async def get_readme(\n"
" owner: str, repo: str\n"
") -> str:\n"
" '''Fetch README for a repo.'''\n"
" async with httpx.AsyncClient() as client:\n"
" resp = await client.get(\n"
" f'https://api.github.com'\n"
" f'/repos/{owner}/{repo}'\n"
" f'/readme',\n"
" headers={'Accept':\n"
" 'application/vnd.github.v3.raw'},\n"
" timeout=10.0,\n"
" )\n"
" resp.raise_for_status()\n"
" return resp.text\n"
"\n"
"# --- PROMPTS ---\n"
"\n"
"@mcp.prompt()\n"
"def code_review_prompt(\n"
" owner: str, repo: str\n"
") -> str:\n"
" '''Generate a code review request.'''\n"
" return (\n"
" f'Please review the repository '\n"
" f'{owner}/{repo}. '\n"
" f'Use get_repo_info to fetch '\n"
" f'metadata and list_open_issues '\n"
" f'to find issues that need '\n"
" f'attention. Focus on open '\n"
" f'issues with bug labels.'\n"
" )\n"
"\n"
"# --- RUN ---\n"
"\n"
"if __name__ == '__main__':\n"
" mcp.run(transport='stdio')"
)
def get_claude_desktop_config(self) -> str:
"""Claude Desktop configuration."""
return (
"{\n"
" \"mcpServers\": {\n"
" \"github-tools\": {\n"
" \"command\": \"/path/to/.venv/bin/python\",\n"
" \"args\": [\"/path/to/server.py\"],\n"
" \"env\": {\n"
" \"GITHUB_TOKEN\": \"your-token\"\n"
" }\n"
" }\n"
" }\n"
"}\n"
"\n"
"# macOS: ~/Library/Application Support/Claude/\n"
"# claude_desktop_config.json\n"
"# Windows: %APPDATA%/Claude/\n"
"# claude_desktop_config.json\n"
"# Restart Claude Desktop after editing"
)
def get_http_deploy(self) -> str:
"""Streamable HTTP deployment."""
return (
"# Deploy with Streamable HTTP transport\n"
"# For remote/production use\n"
"\n"
"if __name__ == '__main__':\n"
" mcp.run(\n"
" transport='streamable-http',\n"
" host='0.0.0.0',\n"
" port=8000,\n"
" )\n"
"\n"
"# Or integrate with existing web framework\n"
"# FastMCP ships SSE and Streamable HTTP\n"
"# app builders for framework integration\n"
"\n"
"# For new work, Streamable HTTP is\n"
"# the better default (replaces HTTP+SSE)\n"
"# Use OAuth for authentication\n"
"# Single endpoint: http://host:8000/mcp"
)
def get_testing(self) -> str:
"""Testing with MCP Inspector."""
return (
"# Test with MCP Inspector\n"
"uv run mcp dev server.py\n"
"# or: mcp dev server.py\n"
"\n"
"# Opens MCP Inspector at\n"
"# http://localhost:5173\n"
"\n"
"# Navigate to Tools tab\n"
"# - See registered tools\n"
"# - Call tool with test arguments\n"
"# - Verify JSON-RPC response\n"
"\n"
"# Navigate to Resources tab\n"
"# - See registered resources\n"
"# - Read resource by URI\n"
"\n"
"# Navigate to Prompts tab\n"
"# - See registered prompts\n"
"# - Call prompt with arguments\n"
"\n"
"# Three things matter:\n"
"# 1. FastMCP handles all JSON-RPC\n"
"# plumbing automatically\n"
"# 2. @mcp.tool() auto-generates\n"
"# schema from type hints\n"
"# 3. Docstring is what AI reads\n"
"# to decide tool calls —\n"
"# write it clearly"
)
def get_logging_rules(self) -> dict:
"""Logging rules for MCP servers."""
return {
"stdio_rule": (
"On stdio transports, log to stderr. "
"Never print() to stdout or you "
"break JSON-RPC messages."
),
"correct": (
"import sys\n"
"print('log message', file=sys.stderr)\n"
"# or use logging module"
),
"incorrect": (
"print('log message') # BREAKS JSON-RPC\n"
"# stdout is reserved for protocol messages"
),
"logging_module": (
"import logging\n"
"logging.basicConfig(\n"
" stream=sys.stderr,\n"
" level=logging.INFO\n"
")\n"
"logger = logging.getLogger('mcp-server')"
),
}
def get_server_config(self) -> dict:
"""FastMCP server configuration options."""
return {
"parameters": {
"name": "Internal name (default: mcp-server)",
"title": "Human-readable title",
"description": "Server capabilities description",
"instructions": "Usage instructions for the LLM",
"version": "Semantic version of the server",
"debug": "Enable debug logging (default: False)",
},
"env_vars": {
"MCP_DEBUG": "Boolean for debug mode",
"MCP_LOG_LEVEL": "Log level (DEBUG, INFO, etc.)",
"MCP_WARN_ON_DUPLICATE_TOOLS": (
"Warn if tool name is reused"
),
},
"lifespan": (
"Provide a lifespan context manager "
"for resource setup (database "
"connections) and teardown."
),
}
MCP Server Build Checklist
- [ ] Install MCP Python SDK: pip install 'mcp>=1.27,<2' (pin below 2.x while v2 is in alpha)
- [ ] Import: from mcp.server.fastmcp import FastMCP
- [ ] Initialize: mcp = FastMCP('server-name', instructions='...')
- [ ] FastMCP uses Python type hints and docstrings to auto-generate tool definitions
- [ ] Three primitives: tools (model actions), resources (read-only data), prompts (reusable templates)
- [ ] Register tools: @mcp.tool() async def my_tool(arg: str) -> str
- [ ] Register resources: @mcp.resource('docs://path') def my_resource() -> dict
- [ ] Register prompts: @mcp.prompt() def my_prompt(arg: str) -> str
- [ ] ToolManager, ResourceManager, PromptManager handle storage and lookup
- [ ] Write clear docstrings — AI reads them to decide whether to call a tool
- [ ] Use type hints for all function parameters — auto-generates JSON schema
- [ ] Async functions for I/O operations (API calls, database queries)
- [ ] Run with stdio: mcp.run(transport='stdio') — default for local
- [ ] Run with Streamable HTTP: mcp.run(transport='streamable-http') — for remote
- [ ] stdio: local, single client, Claude Desktop and Cursor, no network overhead
- [ ] Streamable HTTP: remote, many clients, OAuth auth, optional SSE streaming
- [ ] Streamable HTTP replaces HTTP+SSE from protocol version 2024-11-05
- [ ] On stdio: log to stderr — NEVER print() to stdout or you break JSON-RPC
- [ ] Use logging module with stream=sys.stderr for stdio servers
- [ ] stdout is reserved for JSON-RPC protocol messages only
- [ ] Test with MCP Inspector: mcp dev server.py → http://localhost:5173
- [ ] MCP Inspector: Tools tab (call tools), Resources tab (read URIs), Prompts tab
- [ ] FastMCP handles all JSON-RPC plumbing automatically
- [ ] Connect to Claude Desktop: claude_desktop_config.json with mcpServers
- [ ] Config: command (python path), args (script path), env (environment vars)
- [ ] macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
- [ ] Windows: %APPDATA%/Claude/claude_desktop_config.json
- [ ] Restart Claude Desktop after editing config
- [ ] Connect to Cursor: same mcpServers config pattern
- [ ] Server config: name, title, description, instructions, version, debug
- [ ] Environment variables: MCP_DEBUG, MCP_LOG_LEVEL, MCP_WARN_ON_DUPLICATE_TOOLS
- [ ] Use lifespan context manager for resource setup (DB connections) and teardown
- [ ] FastMCPToolset can connect to FastMCP server, transport, script, or HTTP URL
- [ ] Start with local STDIO, move to Streamable HTTP when project grows
- [ ] For new work, Streamable HTTP is the better default
- [ ] MCP is the protocol spec; MCP SDK is the language library implementing the spec
- [ ] Python SDK handles JSON-RPC framing, schema generation, transports
- [ ] You focus on tool logic, not protocol mechanics
- [ ] Use httpx for async HTTP calls in tool implementations
- [ ] Pin mcp>=1.27,<2 until stable 2.x release ships
- [ ] Official SDK matches modelcontextprotocol.io docs
- [ ] Server can expose existing REST APIs inside tool functions
- [ ] Resources are read-only — use for data the AI reads passively
- [ ] Prompts are stored templates any MCP client can call by name
- [ ] Dynamic resources with URI templates: @mcp.resource('docs://{service}')
- [ ] Client discovers capabilities via tools/list, resources/list, prompts/list
- [ ] No custom JSON-RPC code needed for discovery
- [ ] Read MCP developer guide for protocol overview
- [ ] Read build MCP client for client implementation
- [ ] Read Claude Desktop MCP for desktop integration
- [ ] Read MCP transports for transport details
- [ ] Read MCP tools for tool development
- [ ] Test: MCP Inspector shows registered tools, resources, prompts
- [ ] Test: Tool call returns correct JSON-RPC response
- [ ] Test: Resource read returns content via URI
- [ ] Test: Prompt returns templated message with arguments
- [ ] Test: Claude Desktop connects and can invoke tools
- [ ] Test: stdio logging goes to stderr, not stdout
- [ ] Document server name, tools, resources, prompts, transport, and config
FAQ
How do you build an MCP server from scratch in Python?
Use the official MCP Python SDK with FastMCP class. Install mcp>=1.27, instantiate FastMCP, register tools with decorators, run with stdio transport. DigitalOcean: "An MCP server in Python uses the mcp package. Pin mcp>=1.27,<2 until stable 2.x. FastMCP from mcp.server.fastmcp registers tools with @mcp.tool() and runs over stdio by default. MCP has three primitives: tools (model actions), resources (read-only data), prompts (reusable templates)." MCP Docs: "FastMCP class uses Python type hints and docstrings to automatically generate tool definitions. Initialize FastMCP server, implement tools with @mcp.tool(), run with mcp.run(transport=stdio)." Steps: (1) pip install 'mcp>=1.27,<2'. (2) from mcp.server.fastmcp import FastMCP. (3) mcp = FastMCP('server-name'). (4) @mcp.tool() async def my_tool(arg: str) -> str. (5) mcp.run(transport='stdio').
How do you register tools, resources, and prompts in FastMCP?
Use decorators: @mcp.tool() for tools, @mcp.resource() for resources, @mcp.prompt() for prompts. FastMCP auto-generates schemas from type hints and docstrings. DeepWiki: "MCPServer provides decorators for registering the three primary MCP primitives. Tools: @mcp.tool() with ToolManager. Resources: @mcp.resource() with ResourceManager. Prompts: @mcp.prompt() with PromptManager. Managers handle internal storage and lookup logic." Pyrastra: "You write ordinary Python, add a few decorators, and get a Python MCP server that can expose tools, read-only resources, and reusable prompts. Start with local STDIO for Claude Desktop or Cursor, then move to Streamable HTTP." DEV Community: "@mcp.tool() decorator auto-generates a schema from your type hints. The docstring is what the AI reads to decide whether to call this tool — write it clearly." Tools: @mcp.tool() async def get_data(query: str) -> str. Resources: @mcp.resource('docs://checklist') def checklist() -> dict. Prompts: @mcp.prompt() def review_prompt(code: str) -> str.
How do you test an MCP server with MCP Inspector?
Run mcp dev server.py to launch the MCP Inspector at http://localhost:5173. Navigate to Tools tab, call tools with arguments, verify responses. DEV Community: "Run uv run mcp dev server.py to launch the MCP Inspector at http://localhost:5173. Navigate to the Tools tab, call greet with any name, and confirm the response. Three things matter: FastMCP handles all JSON-RPC plumbing, the @mcp.tool() decorator auto-generates a schema from your type hints, and the docstring is what the AI reads to decide whether to call this tool." DigitalOcean: "Point Cursor or Claude Desktop at the venv Python and your script path in mcpServers." Testing steps: (1) uv run mcp dev server.py. (2) Open http://localhost:5173. (3) Check Tools tab for registered tools. (4) Call tool with test arguments. (5) Verify JSON-RPC response. (6) Check Resources and Prompts tabs.
How do you connect an MCP server to Claude Desktop?
Add the server to Claude Desktop's mcpServers config in claude_desktop_config.json with the Python path and script path. DigitalOcean: "Point Cursor or Claude Desktop at the venv Python and your script path in mcpServers." DEV Community: "This guide uses stdio — the server runs as a subprocess and communicates over stdin/stdout. This works out of the box with Claude Desktop and Claude Code." Config: {"mcpServers": {"my-server": {"command": "/path/to/python", "args": ["/path/to/server.py"]}}}. On macOS: ~/Library/Application Support/Claude/claude_desktop_config.json. On Windows: %APPDATA%/Claude/claude_desktop_config.json. Restart Claude Desktop after editing config.
How do you deploy an MCP server with Streamable HTTP transport?
Use FastMCP's Streamable HTTP transport for remote deployments instead of stdio. Pyrastra: "Start with a local STDIO server for Claude Desktop or Cursor, then move the same server to Streamable HTTP when you need a remote endpoint. FastMCP already ships the pieces for that. The server HTTP reference documents both SSE and Streamable HTTP app builders. For new work, Streamable HTTP is the better default." DeepWiki: "MCPServer supports multiple transports. You can run the server directly or integrate it into existing web frameworks." Deploy: (1) Change transport from stdio to streamable-http. (2) mcp.run(transport='streamable-http', host='0.0.0.0', port=8000). (3) Use OAuth for authentication. (4) Single HTTP endpoint (e.g., https://example.com/mcp). (5) Supports many clients. (6) Optional SSE for streaming responses.
Want a self-hosted AI company brain that does all of this out of the box?
Book a demo →