Serveur MCP avec Python, uv et FastMCP

Traduction automatique

Cet article a été traduit automatiquement depuis la version originale en anglais.

Ce tutoriel construit un petit serveur MCP de feature store avec FastMCP, le lance avec uv et le connecte à Claude Desktop. Il s’adresse aux développeurs Python qui veulent exposer un workflow de base de données locale à un hôte MCP. À la fin, vous aurez un environnement épinglé sur le SDK v1, un serveur avec trois outils et une ressource, et une vérification locale à lancer avant de connecter Claude Desktop.


Qu’est-ce qu’un serveur MCP ?

Le Model Context Protocol (MCP) est un protocole ouvert pour connecter des applications d’AI à des systèmes externes. Il remplace une intégration personnalisée distincte pour chaque outil par un seul ensemble de conventions.

Dans ce tutoriel, nous construisons un serveur MCP FeatureStoreLite. Il se place entre un LLM et un feature store, c’est-à-dire une base de données de features ML précalculées. Le serveur expose des outils pour interroger et écrire des vecteurs de features indexés par utilisateur, produit ou document.

Pourquoi le construire ?

Déboguer un pipeline de features oblige généralement à ouvrir du SQL ou à écrire un script jetable pour vérifier une valeur. Avec le serveur en marche, vous posez la question à Claude : « Quel est le vecteur de features de user_123 ? » ou « Montre-moi les métadonnées de product_abc. »

Pourquoi utiliser uv ?

Nous utilisons uv pour installer les paquets, résoudre les dépendances et gérer l’environnement virtuel. La configuration de Claude Desktop lancera ce projet depuis son propre répertoire avec --locked : elle utilise ainsi la dépendance mcp[cli] déclarée ici et les versions enregistrées dans uv.lock.

Outils, ressources et prompts

Un serveur MCP peut proposer trois types d’éléments. La spécification MCP les distingue selon qui décide de les utiliser :

PrimitiveQui la déclencheDans ce serveur
OutilLe modèle décide de l’appelerget_feature, store_feature, list_features
RessourceL’hôte décide comment l’inclure dans le contexteschema://main
PromptL’utilisateur le choisit, comme un modèle de messageNon utilisé ici

Les outils permettent au modèle de demander une action. Les ressources fournissent du contexte par l’intermédiaire de l’hôte ; la section 4.4 montre pourquoi exposer une ressource ne garantit pas qu’une réponse l’utilise.

Vue d’ensemble de l’architecture

Le schéma montre quelles parties tournent sur votre machine et quelles données vont au modèle :

Architecture de FeatureStoreLite : le modèle Claude chez le fournisseur du modèle reçoit votre question, la liste des outils et les tool results, c'est-à-dire des lignes de features.db ; sur votre machine, Claude Desktop et son client MCP dialoguent via stdin et stdout avec le processus du serveur FeatureStoreLite, qui expose trois outils et la ressource schema://main et lit et écrit dans la table SQLite contenant les lignes user_123 et product_abcArchitecture de FeatureStoreLite : le modèle Claude chez le fournisseur du modèle reçoit votre question, la liste des outils et les tool results, c'est-à-dire des lignes de features.db ; sur votre machine, Claude Desktop et son client MCP dialoguent via stdin et stdout avec le processus du serveur FeatureStoreLite, qui expose trois outils et la ressource schema://main et lit et écrit dans la table SQLite contenant les lignes user_123 et product_abc

  1. L’utilisateur pose une question en langage naturel.
  2. Claude Desktop est l’hôte MCP. Il envoie la conversation au modèle Claude, crée un client MCP pour ce serveur et gère la connexion.
  3. Notre serveur FastMCP expose trois outils (get_feature, store_feature, list_features) et une ressource (schema://main).
  4. SQLite est le stockage sous-jacent des vecteurs de features.

L’hôte transmet la liste des outils à Claude. Claude peut demander un tool call, mais c’est le client MCP du serveur concerné qui envoie les messages du protocole. Le serveur et SQLite tournent en local, mais les tool results arrivent au modèle dans la conversation. N’exposez que des données que vous colleriez aussi dans le chat.


1. Configuration et installation

1.1. Installer uv

Si uv n’est pas encore installé, installez-le. Le reste du tutoriel suppose qu’il est dans votre PATH.

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

# Or via Homebrew
brew install uv

1.2. Initialiser le projet

Créez un nouveau répertoire et initialisez un projet Python. uv init crée un pyproject.toml pour vous.

# 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"

Ce tutoriel utilise l’API du SDK Python MCP v1. La documentation officielle du SDK v1 demande aux utilisateurs de la v1 d’épingler mcp>=1.28,<2 ; gardez donc la borne supérieure <2 jusqu’à la migration du code. uv.lock enregistre l’environnement complet résolu après cette commande.

Le code utilise le FastMCP fourni avec le SDK officiel (mcp.server.fastmcp). Le paquet autonome fastmcp est un projet distinct avec une API différente : ne mélangez pas sa documentation avec ce code.


2. Construire le serveur

Deux fichiers, séparés par responsabilité :

  1. database.py gère les opérations SQLite.
  2. featurestore_server.py définit le serveur MCP.

2.1. La couche base de données (database.py)

Ce module gère la connexion SQLite et quelques fonctions utilitaires. Nous y insérons deux lignes d’exemple pour que le serveur ait quelque chose à renvoyer dès la première requête.

Créez 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!")

Initialisez la base de données :

uv run python database.py

2.2. Le serveur MCP (featurestore_server.py)

FastMCP fait l’essentiel du travail. Décorez une fonction Python ordinaire et elle est enregistrée comme outil ou ressource MCP. FastMCP construit le schéma d’entrée de chaque outil à partir des annotations de type et utilise la docstring comme description de l’outil. Le modèle voit les deux : écrivez donc la docstring pour le modèle, en indiquant ce que fait l’outil et à quoi ressemble chaque argument.

Créez 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()

Quatre détails de ce code déterminent ce que le modèle envoie et ce qu’il reçoit :

  • vector typé. Le tool schema demande un tableau non vide de nombres. Avec le SDK 1.30, Pydantic convertit aussi les booléens et les chaînes numériques en floats : [true, false] devient [1.0, 0.0], et ["1.5", "2"] devient [1.5, 2.0]. Il vérifie ensuite que la liste n’est pas vide et que chaque valeur est finie avant l’exécution de la fonction. Si un modèle envoie le vecteur sous forme de chaîne JSON comme "[1, NaN]", FastMCP analyse d’abord la chaîne. allow_inf_nan=False rejette ensuite NaN et les infinis, que l’analyseur JSON de Python accepte par défaut.
  • metadata typé. Le schéma accepte un objet JSON, null ou une valeur omise. allow_nan=False empêche aussi NaN d’entrer dans les métadonnées stockées.
  • Erreurs levées. get_feature lève ValueError pour une clé absente. Une exception levée atteint le client comme une erreur d’outil avec isError: true ; une chaîne renvoyée ressemble à un résultat réussi.
  • Écrasement. store_feature utilise INSERT OR REPLACE et remplace donc une clé existante. La docstring le dit, de sorte que le modèle le sait avant d’appeler l’outil.

3. Tester avec MCP Inspector

Avant de le brancher à Claude, vérifiez le serveur avec le MCP Inspector. C’est une petite interface web qui se connecte au serveur comme le fait un hôte. Vous pouvez y parcourir les ressources du serveur, appeler chaque outil avec vos propres arguments et voir chaque requête et chaque réponse.

Le SDK lance l’Inspector via npx. Cette étape nécessite donc aussi Node.js et npm, avec npx dans votre PATH. Inspector 2.10.1 nécessite Node.js 22.19.0 ou une version ultérieure. Vérifiez les commandes installées, puis lancez l’Inspector :

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

La commande démarre le serveur sous le MCP Inspector via stdio. Utilisez l’URL de navigateur affichée par la commande. Le port de l’interface de l’Inspector dépend de l’implémentation : ne supposez pas un port fixe.

Appelez get_feature avec key="user_123". S’il renvoie le JSON de la ligne d’exemple, le serveur fonctionne. Appelez-le ensuite avec une clé qui n’existe pas : le résultat est une erreur d’outil avec isError: true. Cela vérifie le chemin local du protocole, l’enregistrement des outils, la requête en base et le chemin d’erreur. Cela ne teste pas la façon dont Claude choisit ses outils ; la section 4 s’en charge.

L’Inspector lit le schéma SQL depuis la ressource schema://main :

MCP Inspector affichant la ressource get_schema et l'instruction SQLite CREATE TABLE renvoyée


4. Connecter Claude Desktop

Une fois que l’Inspector confirme que le serveur fonctionne, enregistrez-le dans Claude Desktop.

4.1. Configurer Claude

Modifiez le fichier de configuration de Claude Desktop :

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

Ajoutez votre serveur à l’objet mcpServers :

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

Important : utilisez des chemins absolus pour le répertoire du projet et pour featurestore_server.py. Claude Desktop démarre le serveur comme un processus séparé. uv run découvre un projet depuis son répertoire de travail pour une commande comme mcp ; --directory sélectionne donc le pyproject.toml et le uv.lock de ce projet, et --locked échoue au lieu de modifier ce fichier de verrouillage. La commande uv add de l’étape 1.2 a déjà déclaré mcp[cli] dans le projet.

Le SDK propose aussi un raccourci : uv run mcp install featurestore_server.py écrit l’entrée Claude Desktop pour vous. Dans mcp 1.30, cette entrée exécute uv run --frozen --with mcp[cli] mcp run /ABSOLUTE/PATH/featurestore_server.py. Elle n’a pas de --directory, donc elle n’utilise pas le uv.lock de ce projet, et --with mcp[cli] n’a pas de borne <2. Utilisez-la pour un essai rapide ; la configuration manuelle ci-dessus est celle qui est reproductible.

4.2. Comment fonctionne l’interaction

Voici ce qui s’exécute de bout en bout quand Claude a besoin d’une recherche de feature. Les étapes 1 et 2 ont lieu une fois, au démarrage du serveur par Claude Desktop. Les étapes 3 à 6 ont lieu pour chaque question qui nécessite des données de features.

Diagramme de séquence d'une recherche de user_123 entre le modèle Claude, Claude Desktop, le serveur FeatureStoreLite et features.db : tools/list et les descriptions des outils au démarrage, puis, pour chaque question, la requête get_feature du modèle, un message tools/call, le SELECT SQLite, le résultat JSON et la réponse finale, numérotés de 1 à 6 comme dans la liste ci-dessousDiagramme de séquence d'une recherche de user_123 entre le modèle Claude, Claude Desktop, le serveur FeatureStoreLite et features.db : tools/list et les descriptions des outils au démarrage, puis, pour chaque question, la requête get_feature du modèle, un message tools/call, le SELECT SQLite, le résultat JSON et la réponse finale, numérotés de 1 à 6 comme dans la liste ci-dessous

  1. Claude Desktop démarre un client MCP dédié au serveur et découvre ses outils.
  2. L’hôte met les descriptions des outils à la disposition de Claude.
  3. Claude peut demander un tool call quand la question nécessite des données du feature store.
  4. Le client MCP envoie cette requête au serveur.
  5. Le serveur exécute la fonction Python et renvoie le résultat à l’hôte.
  6. L’hôte peut fournir le résultat à Claude pour la réponse finale.

Cette séparation hôte-client-serveur suit la spécification d’architecture MCP.

4.3. Exemples de requêtes

Redémarrez Claude Desktop et essayez quelques prompts :

  1. “Liste toutes les features disponibles.” Le résultat déterministe contient les deux clés insérées au départ : user_123 et product_abc.

  2. “Récupère le vecteur de features de user_123.” La réponse contient le vecteur et les métadonnées premium de database.py.

  3. “Stocke une nouvelle feature pour new_item avec le vecteur [0.5, 0.5] et les métadonnées {"type": "test"}. Récupère ensuite new_item et affiche le vecteur et les métadonnées stockés.” Après conversion, le vecteur validé doit être non vide et ne contenir que des floats finis. Les métadonnées peuvent être un objet JSON, null ou être omises. La lecture qui suit vérifie l’aller-retour complet écriture/lecture.

  4. “Récupère le vecteur de features de user_999.” get_feature lève une erreur pour la clé absente. Claude devrait dire que la clé n’existe pas, et il peut appeler list_features pour afficher les clés valides.

4.4. Lire les réponses de Claude

Le serveur renvoie des données d’outils et de ressources. Claude choisit l’outil et rédige l’explication autour du résultat.

Cette capture montre une exécution de juin 2025 avec une version antérieure de l’exemple. J’ai demandé à Claude d’afficher le schéma de la base de données :

Claude Desktop fournit un schéma conceptuel incorrect de FeatureStoreLite lors de l’exécution de juin 2025

Claude a décrit le système comme un stockage NoSQL ou de documents et a fourni un schéma conceptuel, mais database.py crée une table SQLite relationnelle. La réponse affichée n’est pas l’instruction CREATE TABLE renvoyée par schema://main. La capture n’indique pas si Claude Desktop a lu cette ressource ni quel contexte Claude a reçu. Elle ne permet donc pas d’établir la cause de l’erreur.

Les ressources sont pilotées par l’application : l’hôte décide comment les inclure dans le contexte du modèle, par sélection de l’utilisateur ou inclusion automatique. MCP n’oblige pas l’hôte à transmettre chaque ressource au modèle. Si le modèle doit demander le schéma par un tool call, exposez-le aussi comme outil.

Une explication plausible du modèle n’est pas ce que l’outil a renvoyé. Quand le détail compte, vérifiez le tool result ou le code source.

4.5. Autres hôtes MCP

Le serveur ne dépend pas de Claude Desktop. Tout hôte qui démarre des serveurs stdio sur la même machine peut exécuter la même commande uv. Claude Desktop utilise le JSON de la section 4.1 ; les deux autres sont :

Un hôte sur une autre machine ne peut pas démarrer un processus stdio sur la vôtre. Pour un serveur partagé par plusieurs personnes, exécutez-le en Streamable HTTP avec mcp.run(transport="streamable-http") et placez une authentification devant.


5. Dépannage

Trouvez quelle partie a échoué avant de modifier le code : les erreurs de démarrage sont dans les logs, les erreurs d’outil se reproduisent dans l’Inspector sans modèle, et une mauvaise réponse demande de comparer le tool result avec le texte de Claude.

  • « Server connection failed » :

    • Consultez les logs dans ~/Library/Logs/Claude/ sous macOS ou %APPDATA%\Claude\logs sous Windows. mcp.log enregistre les événements de connexion. mcp-server-featurestore.log dans le même dossier contient le stderr du serveur lui-même : une trace de démarrage s’y trouve donc (logs de Claude Desktop).
    • Vérifiez que la configuration utilise un chemin absolu, pas un chemin relatif.
    • Vérifiez que uv est dans le PATH de Claude Desktop. Sinon, indiquez le chemin complet du binaire (which uv vous dira où il se trouve).
    • Avec stdio, stdout transporte les messages du protocole. Écrivez les logs sur stderr ; un print() dans le serveur corrompt ce flux et peut casser la connexion.
  • « Tool execution error » :

    • Reproduisez l’erreur dans l’Inspector avec uv run mcp dev featurestore_server.py. L’Inspector affiche le message d’erreur et les arguments qui l’ont provoquée.
    • Vérifiez que features.db est bien créé à côté de database.py. Le chemin vient de get_db_path(), qui le résout par rapport au script : un répertoire de travail différent ne devrait donc pas déplacer le fichier.
  • L’appel réussit, mais la réponse est fausse :

    • Ouvrez le tool result dans le chat et comparez-le avec le texte de Claude. La section 4.4 montre une réponse du modèle qui décrit mal la base de données.

6. Avant qu’un agent l’utilise pour de bon

  • store_feature écrase n’importe quelle clé, y compris les lignes d’exemple. Gardez l’approbation à chaque appel pour les outils d’écriture dans l’hôte, ou déplacez-les vers un serveur séparé que vous n’activez qu’en cas de besoin.
  • Les tool results parviennent au fournisseur du modèle dans la conversation. Ne renvoyez pas de données qui doivent rester sur la machine.
  • Un serveur partagé par plusieurs personnes nécessite Streamable HTTP et une authentification plutôt que stdio (section 4.5).

7. Conclusion

Voilà l’ensemble : un serveur FastMCP, un stockage SQLite et une configuration Claude Desktop qui pointe vers une commande uv run. La même structure convient à la plupart de ce qu’on peut envelopper dans une fonction Python. Remplacez les appels SQLite par un vrai feature store, une API interne ou un registre de modèles, et le serveur reste petit.

Références