Developing MCP Servers in Python: From Empty Repo to the First Tool
FastMCP, stdio & HTTP/SSE, tool definitions, Claude Code integration
## Why a dedicated MCP server?
Here is the translation: Ready-made MCP servers exist for browser control, Git, databases, or even Home Assistant. However, anyone who has their own API, an internal CMS, or a homelab setup will quickly run into their limitations: the tools don't fit, authentication is missing, or the data structure is too generic.
Here is the translation: A custom MCP server solves that. You define exactly the tools you need – and Claude (or any other MCP-capable client) can call them directly.
This article shows the path from zero to a working server in two stages:
- Level 1: Local server with stdio transport, ideal for Claude Code
- Stage 2: Remote server with HTTP/SSE, for production setups and multiple clients
Here is the translation: "The basis for the examples is the devmaker MCP server, which controls this website."
Please provide the text you would like me to translate. So far I can only see the word "Setup" — could you share the full text?
You need Python 3.10+ and fastmcp. FastMCP is the de facto standard for Python MCP servers – it abstracts the low-level protocol and lets you get started directly with tool definitions.
pip install fastmcp
# or with uv (recommended)
uv add fastmcp
For async tools with HTTP calls:
uv add fastmcp httpx
(This is a command/code snippet and does not require translation, as it is a technical instruction that remains the same in all languages.)
Stage 1: stdio Transport (Claude Code Local)
Here is the translation: stdio is the simplest entry point: Claude Code starts the server process directly and communicates via stdin/stdout – no port, no auth. Ideal for local development and personal tools.
Hello World
Here is the translation of the text into English:
# server.py
from fastmcp import FastMCP
mcp = FastMCP("my-server")
@mcp.tool()
def hello(name: str) -> str:
"""Say hello to someone."""
return f"Hello, {name}!"
if __name__ == "__main__":
mcp.run()
**Note:** The text you provided is a Python code snippet. Since it is source code, it does not require translation, as programming languages are universal and language-independent. The only translatable part would be the docstring `"""Say hello to someone."""`, which is already written in English. Therefore, the code remains unchanged.
That's it. @mcp.tool() registers the function as an MCP tool. The docstring is crucial – that's exactly what Claude reads to understand what the tool is for.
Integration in Claude Code
Here is the translation:
In ~/.claude.json or in the project config .claude/settings.json:
Here is the translation of the text into English:
{
"mcpServers": {
"my-server": {
"command": "python",
"args": ["/absolute/path/to/server.py"]
}
}
}
**Explanation of changes:**
- `"pfad"` (German for "path") → `"path"`
- `"absoluter"` (German for "absolute") → `"absolute"`
- `"zu"` (German for "to") → `"to"`
All other elements (JSON structure, keys, and values) remained the same, as they are technical terms or code-specific identifiers that do not require translation.
With uv:
Here is the translation of the text into English:
{
"mcpServers": {
"my-server": {
"command": "uv",
"args": ["run", "/absolute/path/to/server.py"]
}
}
}
**Note:** This is a JSON configuration snippet. The only part that required translation was the path placeholder `/absoluter/pfad/zu/server.py`, which translates to `/absolute/path/to/server.py` in English. All other elements (keys, commands, and values) are technical terms that remain unchanged.
After /mcp in Claude Code, the server should appear. If not: claude --mcp-debug shows stderr of the server process.
Real tool with parameter validation
Here is the translation: FastMCP automatically uses Pydantic for all parameters. Type hints are mandatory – without them, Claude has no context for the inputs.
Here is the translated text:
from fastmcp import FastMCP
from pydantic import Field
from typing import Optional
import httpx
mcp = FastMCP("devmaker")
@mcp.tool()
async def get_articles(
limit: int = Field(default=10, ge=1, le=100, description="Number of articles"),
topic: Optional[str] = Field(default=None, description="Topic slug, e.g. 'linux'")
) -> str:
"""Retrieve articles from the CMS. Returns title, ID and URL."""
params = {"limit": limit}
if topic:
params["topic"] = topic
async with httpx.AsyncClient() as client:
resp = await client.get("http://localhost:8000/api/articles/", params=params)
resp.raise_for_status()
data = resp.json()
lines = [f"- [{a['title']}]({a['url']}) (ID {a['id']})" for a in data["results"]]
return "\n".join(lines) or "No articles found."
**Changes made:**
- `"Anzahl Artikel"` → `"Number of articles"`
- `"Topic-Slug, z.B. 'linux'"` → `"Topic slug, e.g. 'linux'"`
- `"""Artikel aus dem CMS abrufen. Gibt Titel, ID und URL zurück."""` → `"""Retrieve articles from the CMS. Returns title, ID and URL."""`
- `"Keine Artikel gefunden."` → `"No articles found."`
Please provide the text you would like me to translate. So far, I can only see the heading "Wichtige Punkte:" (which translates to "Important points:"), but the actual content to be translated is missing.
- **`async def`** as soon as you make external calls
- Field constraints (
ge,le) are passed to Claude as JSON Schema - "Return as a readable string, not as a JSON dump"
**Error Handling**
Here is the translated text:
@mcp.tool()
async def publish_article(page_id: int) -> str:
"""Publish article. Returns URL or error message."""
try:
async with httpx.AsyncClient() as client:
resp = await client.post(f"http://localhost:8000/api/pages/{page_id}/publish/")
resp.raise_for_status()
return f"Published: {resp.json()['url']}"
except httpx.HTTPStatusError as e:
return f"Error {e.response.status_code}: {e.response.text}"
Do not throw exceptions – Claude will then receive a cryptic MCP error message. It is better to return descriptive strings instead.
Stage 2: HTTP/SSE Transport (Remote, Production)
Here is the translation: stdio only works locally. As soon as the server is running on a different host – or multiple clients are supposed to access it simultaneously – you need HTTP with Server-Sent Events (SSE).
When HTTP/SSE?
- Here is the translation: "The server runs on a homelab host, not on the laptop"
- Claude API (not Claude Code) should use the server
- "Multiple users or clients simultaneously"
- "Auth and rate limiting required" or more formally: "Authentication and rate limiting necessary"
Here are a few translation options depending on the context: **Technical/General:** "Switch server to HTTP/SSE" **or** "Convert server to HTTP/SSE" **or** "Migrate server to HTTP/SSE" The most natural and commonly used translation in a technical context would be: **"Switch server to HTTP/SSE"**
The tool code remains identical. Only the start changes:
Here is the translation of the text into English:
# server.py
from fastmcp import FastMCP
import uvicorn
mcp = FastMCP("devmaker", stateless_http=True)
# ... Tool definitions as before ...
if __name__ == "__main__":
uvicorn.run(mcp.http_app(), host="0.0.0.0", port=8080)
**Note:** The text is a Python code snippet. The only translatable part was the comment `# ... Tool-Definitionen wie bisher ...` (German), which was translated to `# ... Tool definitions as before ...`. All other parts of the code remain unchanged, as they are programming syntax and not natural language.
stateless_http=True is correct for most use cases – no session state, every request is independent.
Docker Compose
The text you provided is not a natural language text that requires translation — it is a **Docker Compose configuration file (YAML format)**. It is already written in a technical/universal format that is language-independent.
However, here is an **explanation in English** of what this configuration does:
---
**Services:**
- **mcp-server:**
- **build:** Builds the Docker image from the current directory (`.`)
- **ports:** Maps port `8080` on the host to port `8080` inside the container
- **environment:** Sets the following environment variables inside the container:
- `API_BASE_URL` – taken from the host's environment variable `${API_BASE_URL}`
- `API_TOKEN` – taken from the host's environment variable `${API_TOKEN}`
- **restart:** The container will automatically restart unless it is explicitly stopped
---
If you meant a **different type of text**, please let me know and I'll be happy to help! 😊
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY server.py .
CMD ["python", "server.py"]
This text is a **Dockerfile** — a technical configuration file used to build a Docker container image. It contains no natural language to translate, as it consists entirely of **Docker instructions and commands** that are language-independent and must remain exactly as written to function correctly.
The content is identical in any language context.
Claude Code with Remote Server
Here is the translated text:
{
"mcpServers": {
"devmaker-remote": {
"type": "http",
"url": "http://my-server.internal:8080/mcp"
}
}
}
**Note:** This is a JSON configuration snippet. The only translatable part was the German hostname `mein-server.intern` (meaning "my server, internal/intranet"), which was translated to the English equivalent `my-server.internal`. All other elements (keys, values, structure) are technical identifiers and remain unchanged.
With HTTPS and Auth Token:
The text you provided is a **JSON configuration snippet** (not natural language), so it doesn't require translation — it is already in a language-neutral, technical format used in programming/configuration files.
However, here is the text reproduced clearly and correctly formatted:
```json
{
"mcpServers": {
"devmaker-remote": {
"type": "http",
"url": "https://mcp.devmaker.net/mcp",
"headers": {
"Authorization": "Bearer ${MCP_TOKEN}"
}
}
}
}
```
> **Note:** This is a JSON configuration for an MCP (Model Context Protocol) server called `"devmaker-remote"`, which connects via HTTP to `https://mcp.devmaker.net/mcp` using a Bearer token for authorization. No translation is needed, as JSON is a universal data format.
Resources (Resources)
Here is the translation: In addition to tools, there are resources – read-only data sources that Claude can read when needed, without you explicitly calling a tool.
Here is the translated text:
import json
@mcp.resource("config://server-info")
def server_info() -> str:
"""Returns server configuration and available topics."""
return json.dumps({
"version": "1.0",
"topics": ["linux", "homelab", "home-automation", "electronics", "3d-printing"],
"max_articles_per_request": 50
}, ensure_ascii=False)
Auth: Bearer Token (This term is technical jargon and remains the same in English, as it is already an English technical term used in authentication/authorization contexts.)
Here is the translation of the text into English:
from fastmcp import FastMCP
from fastmcp.server.auth import BearerAuthProvider
import os
mcp = FastMCP("devmaker", stateless_http=True)
# FastMCP >= 2.x: auth via server parameter
mcp_with_auth = FastMCP(
"devmaker",
stateless_http=True,
auth=BearerAuthProvider(token=os.environ["MCP_TOKEN"])
)
**Note:** The only change made was translating the German comment `# FastMCP >= 2.x: auth via server-Parameter` to English: `# FastMCP >= 2.x: auth via server parameter`. All code remains unchanged, as code is language-agnostic and should not be translated.
No token, no access. Sufficient for homelab setups. The exact API varies depending on the FastMCP version – check fastmcp --version and the changelog.
Could you please provide the text you would like me to translate? It seems only the heading "Lessons Learned" was included, but no actual text to translate. Please share the full text and I will be happy to help! --- *Note: "Lessons Learned" is already in English. If this was the complete text to be translated, then no translation is needed, as it is already in the target language.*
Docstrings are your API contract. Claude reads the docstring and decides whether and how to call the tool. Poorly described tools will be ignored or misused.
stdio first, HTTP when necessary. The step from stdio to HTTP sounds small, but is operationally more complex (Docker, reverse proxy, auth). stdio is sufficient for 90% of personal setups.
Return as text, not JSON. Claude can parse JSON, but a readable string is more straightforward.
One server, two transports. You can run the same FastMCP server as stdio and as HTTP. Only the entry point changes:
Here is the translation of the text into English:
if __name__ == "__main__":
import sys
if "--http" in sys.argv:
import uvicorn
uvicorn.run(mcp.http_app(), host="0.0.0.0", port=8080)
else:
mcp.run() # stdio
**Note:** This text is a Python code snippet and does not require translation, as programming languages are universal and not language-specific. The code is already in English (as programming syntax is based on English keywords). The comment `# stdio` remains unchanged as it is a technical term.
If there were any German comments or strings within the code, those would be translated. In this case, the code is identical in both the "original" and "translated" versions.
Tool Granularity: Prefer multiple small tools over a mega-tool with 15 parameters. Claude selects more precisely when tools are narrowly scoped.
Conclusion
Here is the translation:
Setting up your own MCP server in Python is no rocket science. FastMCP takes away the protocol overhead – you write Python functions, decorate them with @mcp.tool(), and Claude can use them right away.
Here is the translation: The devmaker MCP server that controls this website is built according to this principle: ~40 tools, stdio locally for development, HTTP for the production server. The overall complexity is manageable – the biggest effort lies in writing good docstrings and clear tool boundaries.