Blogs / Einen MCP-Server in Python bauen

Einen MCP-Server in Python bauen

Veröffentlicht
5. Oktober 2026
Autor
Faizan Nadeem
Tags
MCP Python AI Agent Tools Backend Development
A dark workshop filled with hand tools hanging on a pegboard and wood pieces on a workbench
Photo by Minh Đức on Unsplash

Die meisten MCP-Tutorials zeigen dir ein „Hello World"-Tool – eine Funktion, zwei Argumente, ein Print-Statement – und hören genau dort auf, wo die eigentlichen Entscheidungen anfangen. Ein Server mit einem einzigen Tool braucht kein Design; der braucht einen Decorator. Die eigentliche Arbeit beginnt, sobald du mehr als eine Sache exponieren willst und entscheiden musst, was ein „Tool" ist im Vergleich zu dem, was eigentlich eine „Ressource" sein sollte, ob einem LLM-Client vertraut werden sollte, alles in deinem Server unauthentifiziert aufzurufen, und wie viel von deiner internen API-Oberfläche du einem Modell überlassen willst, das selbst entscheidet, was es wann aufruft.

Dieser Beitrag baut von Grund auf einen MCP-Server in Python – er deckt alle drei vom Protokoll definierten Primitiven ab: Tools, Ressourcen und Prompts – und behandelt die Designentscheidungen genauso ernst wie den Code. Wenn du diesen Server so baust, dass er hinter einem Agenten auf Basis von LangGraph sitzt, ist der Punkt, an dem der Kontrollfluss eines Agenten komplex genug wird, um überhaupt einen Graphen zu benötigen, eine eigene Entscheidung, die du verstehen solltest, bevor du entscheidest, wie viel deiner API du darüber exponieren willst.

Du wirst lernen:

  • Den Unterschied zwischen einem Tool, einer Ressource und einem Prompt in MCP und wann du welches davon verwendest
  • Wie du alle drei mit der FastMCP-Schnittstelle des Python-SDK baust
  • Wie du Tool-Ein- und -Ausgaben so strukturierst, dass ein LLM sie zuverlässig nutzen kann
  • Die Tool-Surface-Entscheidung: was du exponieren, was du intern halten solltest und warum „alles exponieren" ein Fehler ist
  • Ein praxistaugliches Authentifizierungssetup für einen Server, der nicht nur auf localhost läuft
  • Wie du deinen Server lokal mit dem MCP Inspector testest, bevor du einen echten Client anschließt

Inhaltsverzeichnis

  1. Die drei Primitiven
  2. Das Python-SDK einrichten
  3. Tools bauen
  4. Ressourcen bauen
  5. Prompts bauen
  6. Fehler so behandeln, wie ein Modell damit umgehen kann
  7. Alles zusammensetzen
  8. Tools versionieren ohne Clients zu brechen
  9. Die Tool-Surface-Entscheidung
  10. Authentifizierung, bevor du es irgendwo veröffentlichst
  11. Lokal testen mit dem MCP Inspector
  12. Fazit

Die drei Primitiven

MCP definiert drei verschiedene Wege, auf denen ein Server einem Client Fähigkeiten anbieten kann – sie zu vermischen ist der bei weitem häufigste Designfehler in einem ersten Server.

Tools sind Aktionen – Funktionen, die das Modell aufrufen kann, mit typisierten Eingaben und einem Rückgabewert. Ein Tool hat Seiteneffekte oder erledigt Arbeit: eine E-Mail versenden, eine Datenbank abfragen, eine Berechnung durchführen. Das Modell entscheidet anhand der Beschreibung, wann es ein Tool mit welchen Argumenten aufruft.

Ressourcen sind Daten, die der Client lesen kann, identifiziert durch einen URI, die das Modell nicht so sehr „aufrufen" als vielmehr „nachschauen" muss. Stell dir eine Ressource eher wie einen GET-Endpunkt vor als einen Funktionsaufruf – sie ist für Inhalte gedacht, die als Kontext verfügbar sein sollen, nicht für etwas, über das das Modell mit bestimmten Parametern nachdenken muss.

Prompts sind wiederverwendbare, parametrisierte Nachrichtenvorlagen, die der Client einem Benutzer anzeigen kann – denk an ein Slash-Command-Menü, nicht an etwas, das das Modell autonom aufruft. Ein Prompt wie /summarize-ticket nimmt eine Ticket-ID und expandiert in eine vollständige Prompt-Vorlage; der Benutzer (oder die Client-UI) wählt ihn aus, das Modell entscheidet das nicht von selbst.

Diese Aufteilung korrekt hinzubekommen ist wichtig, weil sie verändert, wer die Kontrolle hat. Ein Tool wird modellinitiiert. Eine Ressource ist typischerweise clientinitiiert (der Client entscheidet, welchen Kontext er anhängt). Ein Prompt ist benutzerinitiiert. Eine „Ressource" zu bauen, die eigentlich Argumente benötigt und Seiteneffekte hat, ist ein Zeichen dafür, dass es ein Tool hätte sein sollen.

Das Python-SDK einrichten

Das offizielle Python-SDK liefert eine FastMCP-Klasse mit, die das JSON-RPC-Fundament, die Schema-Generierung aus Type Hints und das Transport-Setup übernimmt – ein minimaler Server ist also nur ein paar Zeilen.

pip install "mcp[cli]"
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Support Tools")

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

stdio-Transport ist die richtige Voreinstellung für die lokale Entwicklung und für Server, die ein Client als Subprocess startet – das ist es, was eine Editor-Integration oder ein lokaler Agent typischerweise verwendet. Für einen Server, der über das Netzwerk erreichbar sein muss, gibt mcp.run(transport="streamable-http") denselben Server über HTTP aus, ohne dass sich die Tool-, Ressourcen- oder Prompt-Definitionen unten ändern.

Tools bauen

Ein Tool ist einfach eine Python-Funktion mit einem @mcp.tool()-Decorator. Das SDK liest deine Type Hints und deinen Docstring, um das JSON-Schema zu generieren, das der Client sieht – das ist auch die einzige Information des Modells darüber, wann und wie es aufgerufen werden soll, also behandle den Docstring als die API-Dokumentation, die er tatsächlich ist.

from pydantic import BaseModel, Field

class RefundResult(BaseModel):
    refund_id: str
    status: str
    amount: float

@mcp.tool()
def issue_refund(
    order_id: str = Field(description="The order ID to refund, e.g. 'ord_8213'"),
    amount: float = Field(description="Refund amount in USD, must be <= the order total"),
    reason: str = Field(description="Short reason for the refund, shown to the customer"),
) -> RefundResult:
    """Issue a refund for a completed order. Only works on orders older than 24 hours."""
    order = get_order(order_id)
    if order.total < amount:
        raise ValueError(f"Refund amount {amount} exceeds order total {order.total}")
    refund = process_refund(order_id, amount, reason)
    return RefundResult(refund_id=refund.id, status=refund.status, amount=amount)

Ein paar Dinge, über die es sich lohnt, bewusst nachzudenken:

  • Echte Exceptions bei ungültiger Eingabe werfen. Das SDK wandelt eine nicht abgefangene Exception in ein Fehlerergebnis um, das das Modell sieht – genau das ist, was du willst. Ein Modell, das einen klaren Fehler „Erstattungsbetrag überschreitet Bestellsumme" erhält, kann sich beim nächsten Aufruf oft selbst korrigieren, während ein Tool, das stillschweigend Falsches tut, gar nicht abgefangen werden kann.
  • Strukturierte Daten zurückgeben, kein formatierter String. Ein Pydantic-Modell (oder ein dict) zurückzugeben gibt dem Client – und allem nachgelagerten Code – etwas, das er zuverlässig parsen kann, statt dass ein Modell einen Satz neu parsen muss, den du für Menschen geschrieben hast.
  • Jedes Tool soll nur eine Sache tun. Ein Tool namens manage_order, das einen action: str-Parameter nimmt („refund", „cancel", „resend"), schiebt eine Entscheidung, für die das Protokoll gebaut ist – welches Tool aufgerufen werden soll – in einen String, den das Modell exakt richtig haben muss. Separate Tools mit separaten, spezifischen Namen sind für ein Modell einfacher korrekt auszuwählen und für dich einfacher individuell hinter Auth oder einem Bestätigungsschritt zu sichern.

Async-Tools funktionieren genauso – deklariere die Funktion einfach als async def, und das SDK kümmert sich darum:

@mcp.tool()
async def lookup_shipment_status(tracking_number: str) -> str:
    """Look up the current status of a shipment by tracking number."""
    async with httpx.AsyncClient() as client:
        resp = await client.get(f"https://carrier.example.com/track/{tracking_number}")
        resp.raise_for_status()
        return resp.json()["status"]

Ressourcen bauen

Ressourcen werden mit einem URI-Template registriert, statt mit beliebigen Argumenten aufgerufen. Ein Client liest eine Ressource anhand ihres URIs; der Server löst ihn zu Inhalt auf.

@mcp.resource("orders://{order_id}/summary")
def order_summary(order_id: str) -> str:
    """A human-readable summary of an order, for use as context."""
    order = get_order(order_id)
    return (
        f"Order {order.id}: {len(order.items)} items, "
        f"total ${order.total:.2f}, status: {order.status}"
    )

@mcp.resource("config://support-policies")
def support_policies() -> str:
    """Static policy text every support conversation should have as context."""
    return SUPPORT_POLICY_TEXT

Das URI-Schema (orders://, config://) ist vollständig deine eigene Konvention – MCP standardisiert nicht, was Schemas bedeuten, es verlangt nur, dass Ressourcen per URI adressierbar sind. Verwende statische Ressourcen wie config://support-policies für Inhalte, die immer als Hintergrundkontext verfügbar sein sollen, unabhängig davon, worum es in dem Gespräch geht, und Template-Ressourcen wie orders://{order_id}/summary für Inhalte, die der Client basierend auf dem tatsächlich Besprochenen abruft.

Prompts bauen

Ein Prompt gibt eine oder mehrere Nachrichten zurück, optional parametrisiert, gedacht um von einem Benutzer ausgewählt statt vom Modell mitten im Reasoning aufgerufen zu werden.

from mcp.server.fastmcp.prompts import base

@mcp.prompt()
def draft_refund_response(order_id: str, tone: str = "empathetic") -> list[base.Message]:
    """Draft a customer-facing message explaining a refund decision."""
    order = get_order(order_id)
    return [
        base.UserMessage(
            f"Write a {tone} message to the customer explaining the refund decision "
            f"for order {order.id}. Order details: {order.items}, total ${order.total:.2f}."
        )
    ]

Ein Client mit UI – ein Chat-Client, ein internes Support-Tool – kann das als auswählbare Aktion anzeigen („Erstattungsantwort verfassen"), die in einen vollständig ausgefüllten Prompt mit den echten Daten der Bestellung expandiert, statt den Benutzer zu zwingen, all diesen Kontext jedes Mal von Hand einzutippen.

Fehler so behandeln, wie ein Modell damit umgehen kann

Es lohnt sich, zwei Arten von Fehlern zu unterscheiden, die ein Tool treffen kann, da das SDK sie unterschiedlich behandelt und dein Code das auch sollte. Eine nicht abgefangene Exception wird zu einem Tool-Call-Fehler, den der Client als Fehlschlag sieht – angemessen bei echten Bugs oder unbehebbaren Zuständen. Ein Validierungsfehler, den das Modell beheben könnte – eine fehlerhafte Bestellungs-ID, ein Betrag außerhalb eines erlaubten Bereichs – wird besser als normales, strukturiertes Ergebnis zurückgegeben, das beschreibt, was schiefgelaufen ist, weil das dem Modell ein klares Faktum gibt, aus dem es schlussfolgern kann, statt eines undurchsichtigen Fehlerobjekts.

class RefundOutcome(BaseModel):
    success: bool
    refund_id: str | None = None
    error: str | None = None

@mcp.tool()
def issue_refund(order_id: str, amount: float, reason: str) -> RefundOutcome:
    """Issue a refund for a completed order. Returns success=False with an
    explanation if the refund can't be processed, rather than raising."""
    order = get_order(order_id)
    if order is None:
        return RefundOutcome(success=False, error=f"No order found with ID {order_id}")
    if amount > order.total:
        return RefundOutcome(
            success=False,
            error=f"Refund amount {amount} exceeds order total {order.total:.2f}",
        )
    refund = process_refund(order_id, amount, reason)
    return RefundOutcome(success=True, refund_id=refund.id)

Dieses Muster – ein Ergebnistyp mit einem success-Flag statt einer Exception – führt in der Praxis zu besserem Agentenverhalten, weil das Modell den Fehlschlag als Teil des normalen Gesprächs wahrnimmt statt als Unterbrechung der Interaktion. Echte Exceptions sollte man für Fälle reservieren, in denen das Weitermachen tatsächlich keinen Sinn ergibt: eine Datenbankverbindung, die ausgefallen ist, eine intern verletzte Invariante, etwas, das kein nochmaliger Versuch des Modells mit anderen Argumenten beheben würde.

Alles zusammensetzen

Eine einzelne Datei, die die obigen Teile kombiniert – ein Lese-Tool, ein Schreib-Tool, eine Ressource und ein Prompt – hat die realistische Form eines ersten echten Servers: klein genug, um ihn im Kopf zu halten, und vollständig genug, um tatsächlich nützlich hinter einem Agenten zu sein:

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.prompts import base
from pydantic import BaseModel

mcp = FastMCP("Support Tools")

class RefundOutcome(BaseModel):
    success: bool
    refund_id: str | None = None
    error: str | None = None

@mcp.tool()
def lookup_order(order_id: str) -> dict:
    """Look up an order by ID. Read-only, safe to call freely."""
    order = get_order(order_id)
    return order.model_dump() if order else {"error": "not found"}

@mcp.tool()
def issue_refund(order_id: str, amount: float, reason: str) -> RefundOutcome:
    """Issue a refund. Has a real side effect — use only after confirming with the user."""
    order = get_order(order_id)
    if order is None or amount > order.total:
        return RefundOutcome(success=False, error="Invalid order or amount")
    refund = process_refund(order_id, amount, reason)
    return RefundOutcome(success=True, refund_id=refund.id)

@mcp.resource("config://support-policies")
def support_policies() -> str:
    """Static policy text every support conversation should have as context."""
    return SUPPORT_POLICY_TEXT

@mcp.prompt()
def draft_refund_response(order_id: str, tone: str = "empathetic") -> list[base.Message]:
    """Draft a customer-facing message explaining a refund decision."""
    order = get_order(order_id)
    return [base.UserMessage(f"Write a {tone} message about the refund for order {order.id}.")]

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

Beachte die Form: ein Lese-Tool ohne nennenswerte Nachteile beim Aufrufen, ein Schreib-Tool, das ein strukturiertes Ergebnis zurückgibt statt zu werfen, eine Ressource für immer relevanten statischen Kontext und ein Prompt für eine benutzergetriggerte Aktion. Das ist eine Vorlage, die es wert ist, beizubehalten, wenn du mehr Tools hinzufügst – bevor du Tool Nummer neun hinzufügst, frag dich, zu welcher dieser vier Kategorien es tatsächlich gehört.

Tools versionieren ohne Clients zu brechen

Sobald ein Server echte Clients hat, die von ihm abhängen, ist das Ändern des Schemas eines Tools eine Breaking Change im gleichen Sinne wie das Ändern der Response-Form eines REST-Endpunkts – ein Client (oder das Modell, mitten in einem Gespräch), der gegen das alte Schema entwickelt wurde, weiß nicht automatisch über das neue Bescheid. Ein paar Praktiken, die das handhabbar machen:

  • Optionale Parameter mit Defaults hinzufügen, keine bestehenden Pflichtparameter ändern. issue_refund ein optionales currency: str = "USD" hinzuzufügen ist sicher. amount in refund_amount umzubenennen bricht jede bestehende Aufrufstelle und jedes Modell, das die alte Beschreibung bereits in seinem Kontext gesehen hat.
  • Den Tool-Namen für wirklich inkompatible Änderungen versionieren. Wenn sich das Verhalten eines Tools auf eine nicht abwärtskompatible Weise ändern muss, liefere issue_refund_v2 neben dem Original statt issue_refund unter bestehenden Aufrufenden zu mutieren, und deprecate das alte nach deinem eigenen Zeitplan.
  • Beschreibungen synchron mit dem tatsächlichen Verhalten halten. Ein veralteter Docstring ist schlimmer als keiner – er führt das Modell aktiv in die Irre darüber, was ein Tool tut oder welche Einschränkungen gelten, und dieser Fehlermodus ist still, bis jemand bemerkt, dass das Tool falsch aufgerufen wird.

Die Tool-Surface-Entscheidung

Das ist der Teil, der in einem „Hello World"-Tutorial nicht vorkommt: Sobald dein Server echte Tools mit echten Seiteneffekten hat, ist was du exponieren willst eine größere Entscheidung als wie du ein einzelnes Tool implementierst.

Erst lesen, dann schreiben. Ein lookup_order-Tool ist risikoarm – schlimmstenfalls schaut ein verwirrtes Modell die falsche Bestellung nach. Ein issue_refund-Tool ist das nicht – ein Modell, das den Kontext falsch liest und es mit dem falschen Betrag aufruft, hat einen echten, externen Effekt verursacht. Exponiere Lese-Tools großzügig; exponiere Schreib-Tools bedacht, und erwäge, alles Unumkehrbare hinter einem Bestätigungsschritt oder einem Human-in-the-Loop-Muster im aufrufenden Agenten zu sichern, statt allein dem Urteil des Modells zu vertrauen.

Exponiere nicht deine gesamte interne API nur weil du es kannst. Es ist verlockend, jede bestehende interne Funktion als Tool zu verpacken, sobald du das Decorator-Muster draufhast. Widerstehe dem. Jedes Tool, das du exponierst, ist etwas, das das Modell in einem Kontext aufrufen kann, den du nicht vorhergesehen hast – je mehr Tools in einem Server, desto mehr verschlechtert sich die Tool-Auswahlgenauigkeit des Modells, und desto größer ist deine effektive Angriffsfläche, wenn der Client (oder das Modell) jemals kompromittiert oder durch injizierte Inhalte manipuliert wird. Ein Server mit acht gut beschriebenen, schmalen Tools ist zuverlässiger und überprüfbarer als einer mit vierzig.

Denke daran, wer tatsächlich aufruft. Ein Server, der hinter einem einzigen internen Agenten sitzen soll, den dein Team kontrolliert, kann vernünftigerweise mehr exponieren als ein Server, der einem Drittanbieter-Client übergeben werden soll, den du nicht kontrollierst. Schreib auf, bevor du ein Tool hinzufügst, wer es voraussichtlich aufruft und was ihm erlaubt ist zu tun – diese Antwort sollte sowohl die Tool-Oberfläche als auch das Auth-Modell unten informieren.

Aggressiv innerhalb des Tools validieren, nicht nur auf Schema-Ebene. JSON-Schema-Validierung fängt „das Modell hat einen String übergeben, wo ein Int erwartet wurde." Sie fängt nicht „das Modell hat eine gültige Bestell-ID übergeben, die zu einem anderen Kunden gehört als dem in diesem Gespräch." Das ist eine Business-Logic-Prüfung, die deine Tool-Funktion explizit machen muss – das Protokoll weiß nichts von deinem Datenmodell und kann es nicht wissen.

Authentifizierung, bevor du es irgendwo veröffentlichst

Ein Server, der über stdio auf dem eigenen Laptop läuft, hat keine echten Auth-Bedenken – er erbt welche Berechtigungen auch immer der lokale Prozess hat. In dem Moment, in dem du über streamable-http servierst und er von irgendwo außer localhost erreichbar ist, braucht er dieselbe Authentifizierungsdisziplin wie jede andere API.

Die minimal brauchbare Version ist ein Bearer-Token, der geprüft wird, bevor eine Anfrage deine Tool-Logik erreicht:

from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware

VALID_TOKEN = os.environ["MCP_SERVER_TOKEN"]

class BearerAuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        auth = request.headers.get("authorization", "")
        if auth != f"Bearer {VALID_TOKEN}":
            return JSONResponse({"error": "unauthorized"}, status_code=401)
        return await call_next(request)

app = mcp.streamable_http_app()
app.add_middleware(BearerAuthMiddleware)

Das erfüllt die Anforderung „nicht vollständig offen", ist aber immer noch ein einziges gemeinsames Secret ohne Identität pro Aufrufer und ohne Scoping – jeder mit dem Token kann jedes Tool aufrufen, das der Server exponiert. Für alles über einen internen, vertrauenswürdigen Client hinaus plane, in Richtung OAuth-basierter Autorisierung zu gehen, damit Tokens an eine spezifische Identität gebunden sind und auf eine Teilmenge der Tools eingegrenzt werden können statt auf alles-oder-nichts-Zugriff. Was auch immer du wählst, halt die Auth-Prüfung außerhalb deiner Tool-Funktionen – auf der Transport- oder Middleware-Ebene – damit ein Bug in einem Tool nicht versehentlich die Prüfung für ein anderes überspringt.

Lokal testen mit dem MCP Inspector

Bevor du deinen Server an einen echten Agenten anschließt, gibt dir der MCP Inspector eine UI, um Tools aufzurufen, Ressourcen zu lesen und Prompts zu expandieren – direkt, ohne überhaupt einen Client zu schreiben.

mcp dev server.py

Das startet deinen Server und öffnet den Inspector im Browser, wo du issue_refund mit Testargumenten aufrufen und genau sehen kannst, was der Client sehen würde – das Schema, das das Modell bekommt, das strukturierte Ergebnis und jeden Fehlerpfad, den du durch Übergabe schlechter Eingaben triggerst. Es lohnt sich, die Fehlerpfade hier bewusst zu testen: Ruf issue_refund mit einem Betrag auf, der größer als die Bestellsumme ist, und bestätige, dass die Fehlermeldung etwas ist, das ein Modell plausiblerweise zur Selbstkorrektur nutzen kann, nicht ein roher Python-Traceback.

Fazit

Die Mechanik eines MCP-Servers – Decorators, Type Hints, ein Docstring – lässt sich in wenigen Minuten lernen und ist ehrlich gesagt der einfache Teil. Die Entscheidungen, die bestimmen, ob der Server tatsächlich gut ist, sind die, die ein Tutorial nicht liefern kann: welche der drei Primitiven eine bestimmte Fähigkeit wirklich ist, wie schmal du jedes Tool zuschneidest, was intern bleibt und welche Authentifizierung ein Server, der außerhalb von localhost erreichbar ist, tatsächlich braucht, bevor sich jemand anderes damit verbindet. Versteh die Primitiven richtig und gestalte die Tool-Oberfläche bewusst, und der Rest – Auth, Tests, Deployment – ist Standard-Backend-Engineering mit einer MCP-förmigen Schnittstelle.

Weiterlesen: Wie ein MCP-Server Softwareanalysen unterstützt, zeigt Reverse Engineer Anything (REA): Einrichtung und Praxis.

Faizan Nadeem
Autor
Faizan Nadeem

AI/GenAI engineer specialising in agentic RAG, LangGraph, and production LLM systems with Python and FastAPI. 2 years building AI agents, RAG pipelines, and the backends behind them.

Weitere Artikel