MCP-Server mit Python, uv und FastMCP

Automatische Übersetzung

Dieser Artikel wurde automatisch aus der englischen Originalversion übersetzt.

Dieses MCP-Server-Tutorial baut mit FastMCP einen kleinen Feature-Store-Server, startet ihn mit uv und verbindet ihn mit Claude Desktop. Es richtet sich an Python-Entwickler, die einen lokalen Datenbank-Workflow für einen MCP-Host bereitstellen wollen. Am Ende hast du eine Umgebung mit festgelegtem v1-SDK, einen Server mit drei Tools und einer Resource sowie einen lokalen Test, den du ausführst, bevor du Claude Desktop verbindest.


Was ist ein MCP-Server?

Das Model Context Protocol (MCP) ist ein offenes Protokoll, das AI-Anwendungen mit externen Systemen verbindet. Es ersetzt eine eigene Integration für jedes Tool durch einen einheitlichen Satz von Konventionen.

In diesem Tutorial bauen wir einen FeatureStoreLite-MCP-Server. Er sitzt zwischen einem LLM und einem Feature Store, also einer Datenbank mit vorberechneten ML-Features. Der Server stellt Tools bereit, um Feature-Vektoren abzufragen und zu schreiben, die über Nutzer, Produkt oder Dokument adressiert werden.

Warum das Ganze?

Wer eine Feature-Pipeline debuggt, landet meist in SQL oder schreibt ein Wegwerfskript, um einen Wert zu prüfen. Läuft der Server, fragst du stattdessen Claude: “Wie lautet der Feature-Vektor für user_123?” oder “Zeig mir die Metadaten für product_abc.”

Warum uv?

Wir nutzen uv, um Pakete zu installieren, Abhängigkeiten aufzulösen und die virtuelle Umgebung zu verwalten. Die Claude-Desktop-Konfiguration startet dieses Projekt aus seinem eigenen Verzeichnis mit --locked. So nutzt sie die hier deklarierte Abhängigkeit mcp[cli] und die in uv.lock festgehaltenen Versionen.

Tools, Resources und Prompts

Ein MCP-Server kann drei Arten von Dingen anbieten. Die MCP-Spezifikation unterscheidet sie danach, wer entscheidet, sie zu verwenden:

PrimitiveWer löst es ausIn diesem Server
ToolDas Model entscheidet, es aufzurufenget_feature, store_feature, list_features
ResourceDer Host entscheidet, wie sie in den Kontext aufgenommen wirdschema://main
PromptDer Nutzer wählt ihn aus, wie eine gespeicherte VorlageHier nicht verwendet

Tools lassen das Model eine Aktion anfordern. Resources liefern über den Host Kontext; Abschnitt 4.4 zeigt, warum eine bereitgestellte Resource nicht garantiert, dass eine Antwort sie nutzt.

Architekturüberblick

Das Diagramm zeigt, welche Teile auf deinem Rechner laufen und welche Daten an das Model gehen:

FeatureStoreLite-Architektur: Das Claude-Model beim Model-Anbieter erhält deine Frage, die Tool-Liste und die Tool Results, also Zeilen aus features.db; auf deinem Rechner sprechen Claude Desktop und sein MCP-Client über stdin und stdout mit dem FeatureStoreLite-Serverprozess, der drei Tools und die Resource schema://main bereitstellt und die SQLite-Tabelle mit den Zeilen user_123 und product_abc liest und schreibtFeatureStoreLite-Architektur: Das Claude-Model beim Model-Anbieter erhält deine Frage, die Tool-Liste und die Tool Results, also Zeilen aus features.db; auf deinem Rechner sprechen Claude Desktop und sein MCP-Client über stdin und stdout mit dem FeatureStoreLite-Serverprozess, der drei Tools und die Resource schema://main bereitstellt und die SQLite-Tabelle mit den Zeilen user_123 und product_abc liest und schreibt

  1. Der Nutzer stellt eine Frage in natürlicher Sprache.
  2. Claude Desktop ist der MCP-Host. Es sendet die Konversation an das Claude-Model, erstellt einen MCP-Client für diesen Server und verwaltet die Verbindung.
  3. Unser FastMCP-Server stellt drei Tools (get_feature, store_feature, list_features) und eine Resource (schema://main) bereit.
  4. SQLite ist der Speicher für die Feature-Vektoren.

Der Host übergibt die Tool-Liste an Claude. Claude kann einen Tool Call anfordern, aber der MCP-Client pro Server sendet die Protokollnachrichten. Der Server und SQLite laufen lokal, doch Tool Results gehen als Teil der Konversation an das Model. Stelle nur Daten bereit, die du auch in den Chat einfügen würdest.


1. Setup und Installation

1.1. uv installieren

Falls du uv noch nicht hast, installiere es. Der Rest des Tutorials setzt voraus, dass es in deinem PATH liegt.

# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Or via Homebrew
brew install uv

1.2. Projekt initialisieren

Lege ein neues Verzeichnis an und initialisiere ein Python-Projekt. uv init erstellt dir eine pyproject.toml.

# Create project directory
mkdir mcp-featurestore
cd mcp-featurestore

# Initialize Python project
uv init

# Add the MCP SDK with CLI tools
uv add "mcp[cli]>=1.28,<2"

Dieses Tutorial nutzt die API des MCP Python SDK v1. Die offizielle v1-SDK-Dokumentation weist v1-Nutzer an, mcp>=1.28,<2 festzulegen. Behalte die Obergrenze <2 also bei, bis du den Code migrierst. uv.lock hält nach diesem Befehl die vollständig aufgelöste Umgebung fest.

Der Code nutzt das FastMCP, das im offiziellen SDK enthalten ist (mcp.server.fastmcp). Das eigenständige Paket fastmcp ist ein separates Projekt mit anderer API. Mische deshalb dessen Dokumentation nicht mit diesem Code.


2. Den Server bauen

Zwei Dateien, getrennt nach Zuständigkeit:

  1. database.py übernimmt die SQLite-Operationen.
  2. featurestore_server.py definiert den MCP-Server.

2.1. Die Datenbankschicht (database.py)

Dieses Modul verwaltet die SQLite-Verbindung und ein paar Hilfsfunktionen. Wir füllen es mit zwei Beispielzeilen, damit der Server bei der ersten Abfrage etwas zurückgeben kann.

Erstelle database.py:

# database.py
import json
import os
import sqlite3

def get_db_path() -> str:
    """Get the database path - always in the script's directory"""
    script_dir = os.path.dirname(os.path.abspath(__file__))
    return os.path.join(script_dir, "features.db")

def init_db() -> None:
    """Initialize the feature store database with table and sample data"""
    conn = sqlite3.connect(get_db_path())
    conn.execute("""
        CREATE TABLE IF NOT EXISTS features (
            key TEXT PRIMARY KEY,
            vector TEXT NOT NULL,
            metadata TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)

    # Sample data for experimentation
    example_features = [
        (
            "user_123",
            "[0.1, 0.2, -0.5, 0.8, 0.3, -0.1, 0.9, -0.4]",
            json.dumps({"type": "user", "id": 123, "segment": "premium"}),
        ),
        (
            "product_abc",
            "[0.7, -0.3, 0.4, 0.1, -0.8, 0.6, 0.2, -0.5]",
            json.dumps({"type": "product", "id": "abc", "category": "electronics"}),
        ),
    ]

    # Insert if not exists
    for key, vector, metadata in example_features:
        try:
            conn.execute(
                "INSERT INTO features (key, vector, metadata) VALUES (?, ?, ?)",
                (key, vector, metadata),
            )
        except sqlite3.IntegrityError:
            pass  # Already exists

    conn.commit()
    conn.close()

def get_db_connection() -> sqlite3.Connection:
    """Get a database connection"""
    return sqlite3.connect(get_db_path())

if __name__ == "__main__":
    init_db()
    print("✅ Database initialized successfully!")

Initialisiere die Datenbank:

uv run python database.py

2.2. Der MCP-Server (featurestore_server.py)

FastMCP erledigt den größten Teil der Arbeit. Dekoriere eine gewöhnliche Python-Funktion, und sie wird als MCP-Tool oder -Resource registriert. FastMCP erzeugt das Eingabeschema jedes Tools aus den Type Hints und nutzt den Docstring als Tool-Beschreibung. Das Model sieht beides. Schreibe den Docstring deshalb für das Model: Sage, was das Tool tut und wie jedes Argument aussieht.

Erstelle featurestore_server.py:

# featurestore_server.py
import json
from typing import Annotated, Any

from mcp.server.fastmcp import FastMCP
from pydantic import Field

from database import get_db_connection, init_db

# Initialize the MCP Server
mcp = FastMCP("FeatureStoreLite")

# Ensure DB is ready when server starts
init_db()

# A float that rejects NaN and infinities
FiniteFloat = Annotated[float, Field(allow_inf_nan=False)]

@mcp.resource("schema://main")
def get_schema() -> str:
    """The CREATE TABLE statements of the feature store database."""
    conn = get_db_connection()
    try:
        schema = conn.execute(
            "SELECT sql FROM sqlite_master WHERE type='table'"
        ).fetchall()
        return "\n".join(sql[0] for sql in schema if sql[0]) or "No tables found."
    finally:
        conn.close()

@mcp.tool()
def store_feature(
    key: str,
    vector: Annotated[list[FiniteFloat], Field(min_length=1)],
    metadata: dict[str, Any] | None = None,
) -> str:
    """Store a feature vector under key. Overwrites an existing key.

    vector: non-empty list of finite numbers, e.g. [0.1, 0.2].
    metadata: optional JSON object, e.g. {"type": "user"}.
    """
    metadata_json = None if metadata is None else json.dumps(metadata, allow_nan=False)
    conn = get_db_connection()
    try:
        conn.execute(
            "INSERT OR REPLACE INTO features (key, vector, metadata) VALUES (?, ?, ?)",
            (key, json.dumps(vector), metadata_json),
        )
        conn.commit()
    finally:
        conn.close()
    return f"Stored feature '{key}'"

@mcp.tool()
def get_feature(key: str) -> str:
    """Return the vector and metadata stored under key, as JSON.

    Call list_features first if you do not know the key.
    """
    conn = get_db_connection()
    try:
        row = conn.execute(
            "SELECT vector, metadata FROM features WHERE key = ?", (key,)
        ).fetchone()
    finally:
        conn.close()

    if row is None:
        raise ValueError(f"Feature '{key}' not found. Call list_features for valid keys.")
    return json.dumps(
        {
            "key": key,
            "vector": json.loads(row[0]),
            "metadata": json.loads(row[1]) if row[1] else None,
        },
        indent=2,
    )

@mcp.tool()
def list_features() -> str:
    """Return a JSON array of every stored feature key."""
    conn = get_db_connection()
    try:
        rows = conn.execute("SELECT key FROM features").fetchall()
        return json.dumps([row[0] for row in rows])
    finally:
        conn.close()

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

Vier Details in diesem Code bestimmen, was das Model sendet und was es zurückbekommt:

  • Typisierter vector. Das Tool Schema verlangt ein nicht leeres Array von Zahlen. Mit SDK 1.30 wandelt Pydantic auch boolesche Werte und numerische Strings in Floats um: [true, false] wird zu [1.0, 0.0], und ["1.5", "2"] wird zu [1.5, 2.0]. Danach prüft es vor dem Funktionsaufruf, dass die Liste nicht leer und jeder Wert endlich ist. Sendet ein Model den Vektor als JSON-String wie "[1, NaN]", parst FastMCP zuerst den String. allow_inf_nan=False lehnt dann NaN und Unendlichkeitswerte ab, die Pythons JSON-Parser standardmäßig akzeptiert.
  • Typisierte metadata. Das Schema akzeptiert ein JSON-Objekt, null oder einen ausgelassenen Wert. allow_nan=False hält NaN auch aus den gespeicherten Metadaten heraus.
  • Ausgelöste Fehler. get_feature löst bei einem fehlenden Schlüssel einen ValueError aus. Eine ausgelöste Exception erreicht den Client als Tool-Fehler mit isError: true; ein zurückgegebener String sieht wie ein erfolgreiches Ergebnis aus.
  • Überschreiben. store_feature nutzt INSERT OR REPLACE und ersetzt damit einen vorhandenen Schlüssel. Der Docstring sagt das ausdrücklich, damit das Model es weiß, bevor es das Tool aufruft.

3. Testen mit dem MCP Inspector

Bevor du den Server in Claude einbindest, prüfe ihn mit dem MCP Inspector. Das ist eine kleine Web-Oberfläche, die sich wie ein Host mit dem Server verbindet. Dort kannst du die Resources des Servers durchsuchen, jedes Tool mit eigenen Argumenten aufrufen und jede Anfrage und Antwort sehen.

Das SDK startet den Inspector über npx. Dieser Schritt braucht deshalb auch Node.js und npm mit npx im PATH. Inspector 2.10.1 benötigt Node.js 22.19.0 oder neuer. Prüfe die installierten Befehle und starte dann den Inspector:

node --version
npx --version
uv run mcp dev featurestore_server.py

Der Befehl startet den Server über stdio unter dem MCP Inspector. Nutze die Browser-URL, die der Befehl ausgibt. Der Port der Inspector-Oberfläche hängt von der Implementierung ab. Gehe also nicht von einem festen Port aus.

Rufe get_feature mit key="user_123" auf. Gibt es das JSON der Beispielzeile zurück, funktioniert der Server. Rufe es dann mit einem Schlüssel auf, den es nicht gibt: Das Ergebnis ist ein Tool-Fehler mit isError: true. Das prüft den lokalen Protokollpfad, die Tool-Registrierung, die Datenbankabfrage und den Fehlerpfad. Es testet nicht, wie Claude Tools auswählt; das prüft Abschnitt 4.

Der Inspector liest das SQL-Schema aus der Resource schema://main:

MCP Inspector zeigt die Resource get_schema und die zurückgegebene SQLite-CREATE-TABLE-Anweisung


4. Mit Claude Desktop verbinden

Sobald der Inspector bestätigt, dass der Server funktioniert, registriere ihn in Claude Desktop.

4.1. Claude konfigurieren

Bearbeite die Konfigurationsdatei von Claude Desktop:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%/Claude/claude_desktop_config.json

Füge deinen Server zum Objekt mcpServers hinzu:

{
    "mcpServers": {
        "featurestore": {
            "command": "uv",
            "args": [
                "run",
                "--directory",
                "/ABSOLUTE/PATH/TO/mcp-featurestore",
                "--locked",
                "mcp",
                "run",
                "/ABSOLUTE/PATH/TO/mcp-featurestore/featurestore_server.py"
            ]
        }
    }
}

Wichtig: Verwende absolute Pfade für das Projektverzeichnis und für featurestore_server.py. Claude Desktop startet den Server als eigenen Prozess. uv run findet bei einem Befehl wie mcp ein Projekt über sein Arbeitsverzeichnis. Deshalb wählt --directory die pyproject.toml und die uv.lock dieses Projekts aus; --locked schlägt dann fehl, statt die Lockfile zu ändern. Der Befehl uv add aus Schritt 1.2 hat mcp[cli] bereits im Projekt deklariert.

Das SDK hat auch eine Abkürzung: uv run mcp install featurestore_server.py schreibt den Eintrag für Claude Desktop für dich. In mcp 1.30 führt dieser Eintrag uv run --frozen --with mcp[cli] mcp run /ABSOLUTE/PATH/featurestore_server.py aus. Er hat kein --directory und nutzt daher nicht die uv.lock dieses Projekts, und --with mcp[cli] hat keine Obergrenze <2. Nutze ihn für einen schnellen Versuch; die manuelle Konfiguration oben ist die reproduzierbare.

4.2. Wie die Interaktion abläuft

Das läuft von Anfang bis Ende ab, wenn Claude eine Feature-Abfrage braucht. Die Schritte 1 und 2 laufen einmal, wenn Claude Desktop den Server startet. Die Schritte 3 bis 6 laufen bei jeder Frage, die Feature-Daten braucht.

Sequenzdiagramm einer Abfrage von user_123 zwischen dem Claude-Model, Claude Desktop, dem FeatureStoreLite-Server und features.db: tools/list und die Tool-Beschreibungen beim Start, dann für jede Frage die get_feature-Anfrage des Models, eine tools/call-Nachricht, das SQLite-SELECT, das JSON-Ergebnis und die endgültige Antwort, nummeriert von 1 bis 6 wie in der Liste untenSequenzdiagramm einer Abfrage von user_123 zwischen dem Claude-Model, Claude Desktop, dem FeatureStoreLite-Server und features.db: tools/list und die Tool-Beschreibungen beim Start, dann für jede Frage die get_feature-Anfrage des Models, eine tools/call-Nachricht, das SQLite-SELECT, das JSON-Ergebnis und die endgültige Antwort, nummeriert von 1 bis 6 wie in der Liste unten

  1. Claude Desktop startet einen eigenen MCP-Client für den Server und erkennt dessen Tools.
  2. Der Host stellt Claude die Tool-Beschreibungen zur Verfügung.
  3. Claude kann einen Tool Call anfordern, wenn die Frage Daten aus dem Feature Store braucht.
  4. Der MCP-Client sendet diese Anfrage an den Server.
  5. Der Server führt die Python-Funktion aus und gibt das Ergebnis an den Host zurück.
  6. Der Host kann Claude das Ergebnis für die endgültige Antwort bereitstellen.

Diese Trennung in Host, Client und Server folgt der MCP-Architekturspezifikation.

4.3. Beispielabfragen

Starte Claude Desktop neu und probiere ein paar Prompts aus:

  1. “Liste alle verfügbaren Features auf.” Das deterministische Ergebnis enthält die beiden Beispielschlüssel: user_123 und product_abc.

  2. “Hole den Feature-Vektor für user_123.” Die Antwort enthält den Vektor und die Metadaten premium aus database.py.

  3. “Speichere ein neues Feature für new_item mit dem Vektor [0.5, 0.5] und den Metadaten {"type": "test"}. Hole anschließend new_item und zeige den gespeicherten Vektor und die Metadaten.” Der validierte Vektor muss nach der Umwandlung nicht leer sein und nur endliche Floats enthalten. Metadaten dürfen ein JSON-Objekt oder null sein oder fehlen. Das anschließende Lesen prüft den vollständigen Schreib-Lese-Zyklus.

  4. “Hole den Feature-Vektor für user_999.” get_feature löst für den fehlenden Schlüssel einen Fehler aus. Claude sollte sagen, dass der Schlüssel nicht existiert, und kann list_features aufrufen, um die gültigen Schlüssel zu zeigen.

4.4. Claudes Antworten lesen

Der Server liefert Tool- und Resource-Daten. Claude wählt das Tool und schreibt die Erklärung um das Ergebnis herum.

Dieser Screenshot zeigt einen Durchlauf vom Juni 2025 mit einer früheren Version des Beispiels. Ich bat Claude, das Datenbankschema anzuzeigen:

Claude Desktop gibt im Durchlauf vom Juni 2025 ein falsches konzeptionelles Schema für FeatureStoreLite an

Claude nannte das System einen NoSQL- oder Dokumentenspeicher und gab ein konzeptionelles Schema an, aber database.py erstellt eine relationale SQLite-Tabelle. Die gezeigte Antwort ist nicht die CREATE TABLE-Anweisung, die schema://main zurückgibt. Der Screenshot hält nicht fest, ob Claude Desktop diese Resource gelesen hat oder welchen Kontext Claude erhielt. Er belegt daher nicht die Ursache des Fehlers.

Resources sind anwendungsgesteuert: Der Host entscheidet, wie sie durch Nutzerauswahl oder automatische Aufnahme in den Model-Kontext gelangen. MCP verlangt nicht, dass der Host jede Resource an das Model weitergibt. Soll das Model das Schema durch einen Tool Call anfordern, stelle es zusätzlich als Tool bereit.

Eine plausible Erklärung des Models ist nicht dasselbe wie das, was das Tool zurückgegeben hat. Kommt es auf das Detail an, prüfe das Tool Result oder den Quellcode.

4.5. Andere MCP-Hosts

Der Server hängt nicht von Claude Desktop ab. Jeder Host, der stdio-Server auf demselben Rechner startet, kann denselben uv-Befehl ausführen. Claude Desktop nutzt das JSON aus Abschnitt 4.1; die anderen beiden sind:

Ein Host auf einem anderen Rechner kann auf deinem Rechner keinen stdio-Prozess starten. Für einen Server, den mehrere Personen teilen, betreibe ihn über Streamable HTTP mit mcp.run(transport="streamable-http") und schalte eine Authentifizierung davor.


5. Fehlerbehebung

Finde heraus, welcher Teil fehlgeschlagen ist, bevor du den Code änderst: Startfehler stehen in den Logs, Tool-Fehler lassen sich im Inspector ohne Model wiederholen, und bei einer falschen Antwort vergleichst du das Tool Result mit Claudes Text.

  • “Server connection failed”:

    • Prüfe die Logs in ~/Library/Logs/Claude/ unter macOS oder %APPDATA%\Claude\logs unter Windows. mcp.log protokolliert Verbindungsereignisse. mcp-server-featurestore.log im selben Ordner enthält den stderr des Servers, ein Traceback beim Start steht also dort (Claude-Desktop-Logs).
    • Prüfe, ob die Konfiguration einen absoluten Pfad nutzt, keinen relativen.
    • Prüfe, ob uv im PATH von Claude Desktop liegt. Falls nicht, verweise auf den vollständigen Pfad der Binärdatei (which uv zeigt, wo sie liegt).
    • Bei stdio trägt stdout die Protokollnachrichten. Schreibe Logs nach stderr; ein print() im Server stört diesen Stream und kann die Verbindung unterbrechen.
  • “Tool execution error”:

    • Reproduziere den Fehler im Inspector mit uv run mcp dev featurestore_server.py. Der Inspector zeigt die Fehlermeldung und die Argumente, die sie ausgelöst haben.
    • Prüfe, ob features.db neben database.py angelegt wird. Der Pfad kommt aus get_db_path(), das ihn relativ zum Skript auflöst. Ein wechselndes Arbeitsverzeichnis sollte die Datei also nicht verschieben.
  • Der Aufruf gelingt, aber die Antwort ist falsch:

    • Öffne das Tool Result im Chat und vergleiche es mit Claudes Text. Abschnitt 4.4 zeigt eine Model-Antwort, die die Datenbank falsch beschrieb.

6. Bevor ein Agent den Server produktiv nutzt

  • store_feature überschreibt jeden Schlüssel, auch die Beispielzeilen. Lass für Schreib-Tools im Host die Freigabe pro Aufruf eingeschaltet, oder verlege sie in einen separaten Server, den du nur bei Bedarf aktivierst.
  • Tool Results erreichen den Model-Anbieter als Teil der Konversation. Gib keine Daten zurück, die auf dem Rechner bleiben müssen.
  • Ein Server, den mehrere Personen teilen, braucht statt stdio Streamable HTTP und Authentifizierung (Abschnitt 4.5).

7. Fazit

Das ist alles: ein FastMCP-Server, ein SQLite-Speicher und eine Claude-Desktop-Konfiguration, die auf einen uv run-Befehl zeigt. Dieselbe Form funktioniert für das meiste, was sich in eine Python-Funktion packen lässt. Ersetze die SQLite-Aufrufe durch einen echten Feature Store, eine interne API oder eine Model Registry, und der Server bleibt klein.

Referenzen