MCP Server-tutorial: bouwen met Python, uv en FastMCP
Automatische vertaling Dit artikel is automatisch vertaald vanuit de oorspronkelijke Engelse versie.
Python-ontwikkelaars kunnen een lokale feature-store-workflow omzetten in een versiegebonden FastMCP-server voor Claude Desktop. Daarbij gebruiken ze uv om de omgeving aan te maken, tools en een resource beschikbaar te maken en de server lokaal te verifiëren voordat de desktopverbinding wordt opgezet.
Wat is een MCP-server?
Het Model Context Protocol (MCP) is een open protocol voor het verbinden van AI-applicaties met externe systemen. Het vervangt een afzonderlijke custom integratie voor elke tool door één set conventies.
In deze tutorial bouwen we een FeatureStoreLite MCP-server. Die bevindt zich tussen een LLM en een feature store: een database met vooraf berekende ML-features. De server stelt tools beschikbaar voor het opvragen en opslaan van feature vectors, geïndexeerd op gebruiker, product of document.
Waarom zou je dit bouwen?
Debuggen van een feature pipeline betekent meestal dat je SQL uitvoert of een tijdelijk script schrijft om een waarde te controleren. Met de server actief vraag je Claude in plaats daarvan: “Wat is de feature vector voor user_123?” of “Toon me de metadata voor product_abc.”
Waarom uv gebruiken?
We gebruiken uv om packages te installeren, dependencies op te lossen en de virtual environment te beheren. De Claude Desktop-configuratie voert dit project vanuit de eigen directory uit met --locked. Daardoor gebruikt het de hier gedeclareerde dependency mcp[cli] en de versies die in uv.lock zijn vastgelegd.
Architectuuroverzicht
De vier onderdelen en hun samenhang:
- De gebruiker stelt een vraag in natuurlijke taal.
- Claude Desktop is de MCP-host. Deze maakt één MCP-client voor deze server aan en beheert de verbinding.
- Onze
FastMCP-server steltget_featureenstore_featurebeschikbaar als MCP-tools. - SQLite is de backing store voor de feature vectors.
De host kan de ontdekte tools beschikbaar maken voor Claude. Claude kan een tool call aanvragen, maar de MCP-client per server verstuurt de protocolberichten.
1. Setup en installatie
1.1. uv installeren
Als je uv nog niet hebt, installeer het dan. De rest van deze tutorial gaat ervan uit dat het op je PATH staat.
# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Or via Homebrew
brew install uv
1.2. Het project initialiseren
Maak een nieuwe directory en initialiseer een Python-project. uv init maakt automatisch een pyproject.toml voor je aan.
# 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"
Deze tutorial gebruikt de MCP Python SDK v1 API. De officiële v1 SDK-documentatie schrijft v1-gebruikers voor om mcp>=1.28,<2 vast te pinnen. Laat daarom de bovengrens <2 staan totdat je de code migreert. uv.lock legt na dit commando de volledig opgeloste omgeving vast.
De inline code in dit artikel is de canonieke versie. De companion repository waarnaar in de referenties wordt gelinkt, is een historische versie. Die bevat niet de huidige dependency-pin of de huidige vectorvalidatie.
2. De server bouwen
Twee bestanden, gescheiden naar verantwoordelijkheid:
database.pyhandelt SQLite-operaties af.featurestore_server.pydefinieert de MCP-server.
2.1. De databaselaag (database.py)
Deze module beheert de SQLite-verbinding en enkele helpers. We vullen de database met twee voorbeeldrijen, zodat de server bij de eerste query iets kan retourneren.
Maak database.py aan:
# 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!")
Initialiseer de database:
uv run python database.py
2.2. De MCP-server (featurestore_server.py)
FastMCP doet het meeste werk. Annoteer een gewone Python-functie met de decorator en die wordt geregistreerd als MCP-tool of resource. De docstring wordt de beschrijving die de LLM ziet. Schrijf die voor deze lezer.
Maak featurestore_server.py aan:
# featurestore_server.py
import json
import math
from mcp.server.fastmcp import FastMCP
from database import get_db_connection, init_db
# Initialize the MCP Server
mcp = FastMCP("FeatureStoreLite")
# Ensure DB is ready when server starts
init_db()
def reject_non_finite_json_number(value: str) -> None:
"""Reject NaN and infinities, which are not valid JSON numbers."""
raise ValueError(f"Non-finite JSON number: {value}")
@mcp.resource("schema://main")
def get_schema() -> str:
"""
Resource: Provide the database schema.
Resources provide context to the host application.
The host decides whether to pass that context to the model.
"""
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: str, metadata: str | None = None) -> str:
"""
Tool: Store a feature vector.
Tools are executable functions that LLMs can call to perform actions.
"""
try:
parsed_vector = json.loads(
vector, parse_constant=reject_non_finite_json_number
)
except (json.JSONDecodeError, ValueError):
return "Error: Vector must be valid JSON with finite numbers"
try:
valid_vector = (
isinstance(parsed_vector, list)
and bool(parsed_vector)
and all(
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(value)
for value in parsed_vector
)
)
except OverflowError:
valid_vector = False
if not valid_vector:
return "Error: Vector must be a non-empty JSON array of finite numbers (e.g., '[0.1, 0.2]')"
metadata_json = None
if metadata is not None:
try:
parsed_metadata = json.loads(
metadata, parse_constant=reject_non_finite_json_number
)
except (json.JSONDecodeError, ValueError):
return "Error: Metadata must be valid JSON with finite numbers"
if not isinstance(parsed_metadata, dict):
return "Error: Metadata must be a JSON object (e.g., '{\"type\": \"test\"}')"
metadata_json = json.dumps(parsed_metadata, allow_nan=False)
conn = get_db_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO features (key, vector, metadata) VALUES (?, ?, ?)",
(key, json.dumps(parsed_vector, allow_nan=False), metadata_json),
)
conn.commit()
return f"Successfully stored feature '{key}'"
except Exception as e:
return f"Error: {str(e)}"
finally:
conn.close()
@mcp.tool()
def get_feature(key: str) -> str:
"""
Tool: Retrieve a feature vector by key.
"""
conn = get_db_connection()
try:
row = conn.execute(
"SELECT vector, metadata FROM features WHERE key = ?", (key,)
).fetchone()
if row:
return json.dumps(
{
"key": key,
"vector": json.loads(row[0]),
"metadata": json.loads(row[1]) if row[1] else None,
},
indent=2,
)
return f"Feature '{key}' not found."
finally:
conn.close()
@mcp.tool()
def list_features() -> str:
"""
Tool: List all available feature keys.
"""
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()
De JSON-parser van Python accepteert standaard NaN en infinity-waarden, ook al vallen die buiten de JSON-grammatica voor getallen. De callback parse_constant verwerpt deze notaties, en math.isfinite controleert elk vectorelement voordat SQLite de rij ontvangt. Voor metadata geldt één contract: het moet een JSON-objectstring zijn, en de server parseert en normaliseert die vóór het invoegen. Zie de notities over Python JSON-interoperabiliteit.
3. Testen met MCP Inspector
Voordat je dit aan Claude koppelt, controleer je de server met MCP Inspector. Dit is een kleine web-UI waarmee je tools kunt aanroepen en resources rechtstreeks kunt lezen.
uv run mcp dev featurestore_server.py
Het commando start de server onder MCP Inspector via stdio. Gebruik de browser-URL die het commando afdrukt. De UI-port van Inspector is implementatieafhankelijk; ga dus niet uit van een vaste port.
Dit is hoe mijn oorspronkelijke run uit juni 2025 eruitzag in MCP Inspector v0.14.0. De screenshot legt de stdio-configuratie van Inspector en een geslaagde verbinding vast. Het niet-gepinde --with mcp-argument is historisch; gebruik nu bij het reproduceren van de tutorial het gepinde v1-commando uit dit artikel.

Roep get_feature aan met key="user_123". Als dit de JSON van de seed-rij retourneert, werkt de server.
Dit controleert het lokale protocolpad, de toolregistratie, de database lookup en de JSON-output. Het valideert niet de kwaliteit van vector search. Voeg voor production retrieval tests toe met representatieve queries, verwachte nearest-neighbor-resultaten, distance thresholds, metadatafilters en een evaluation set die bij je workload past.
Dezelfde run maakte alle drie tools en de schema://main-resource zichtbaar. Deze twee screenshots zijn nuttige controles van discovery: Inspector vond de functies en las het SQL-schema dat de server retourneerde.


De toolscreenshot bevat vier rijen: user_123, product_abc, doc_guide_001 en recommendation_engine. Dat was mijn uitgebreidere lokale database op 10 juni 2025. De implementatie van list_features retourneerde objecten met de velden key en created_at. De huidige functie retourneert alleen een JSON-array met key strings en seedt alleen de eerste twee rijen. Vergelijk de historische output daarom niet rij voor rij met de huidige fixture.
4. Verbinding maken met Claude Desktop
Zodra Inspector bevestigt dat de server werkt, registreer je hem bij Claude Desktop.
4.1. Claude configureren
Bewerk je Claude Desktop-configuratiebestand:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%/Claude/claude_desktop_config.json
Voeg je server toe aan het object mcpServers:
{
"mcpServers": {
"featurestore": {
"command": "uv",
"args": [
"run",
"--directory",
"/ABSOLUTE/PATH/TO/mcp-featurestore",
"--locked",
"mcp",
"run",
"/ABSOLUTE/PATH/TO/mcp-featurestore/featurestore_server.py"
]
}
}
}
Belangrijk: gebruik absolute paths voor zowel de projectdirectory als
featurestore_server.py. Claude Desktop start de server als een afzonderlijk proces.uv rundetecteert een project vanuit de working directory voor een commando zoalsmcp. Daardoor selecteert--directorydepyproject.tomlenuv.lockvan dit project;--lockedfaalt vervolgens in plaats van dat lockfile te wijzigen. Het commandouv adduit stap 1.2 heeftmcp[cli]al in het project gedeclareerd.
4.2. Hoe de interactie werkt
Dit gebeurt end-to-end wanneer Claude een feature lookup nodig heeft:
- Claude Desktop start een dedicated MCP-client voor de server en ontdekt de tools.
- De host maakt de toolbeschrijvingen beschikbaar aan Claude.
- Claude kan een tool call aanvragen wanneer de vraag feature-storedata vereist.
- De MCP-client stuurt dat verzoek naar de server.
- De server voert de Python-functie uit en retourneert het resultaat aan de host.
- De host kan het resultaat aan Claude doorgeven voor het uiteindelijke antwoord.
Deze scheiding tussen host, client en server volgt de MCP-architectuurspecificatie. Resources leveren ook context aan de hostapplicatie. MCP vereist niet dat de host elke resource aan het model doorgeeft.
4.3. Voorbeeldqueries
Herstart Claude Desktop en probeer enkele prompts:
-
“List all available features.” Het deterministische resultaat bevat de twee geseede keys:
user_123enproduct_abc. -
“Get the feature vector for user_123.” Het antwoord bevat de vector en de
premium-metadata uitdatabase.py. -
“Store a new feature for
new_itemwith the vector JSON string[0.5, 0.5]and the metadata JSON string{"type": "test"}. Then retrievenew_itemand show the stored vector and metadata.” Beide argumenten moeten geldige JSON bevatten. De write slaagt pas nadat de server de vector en metadata heeft gevalideerd. De daaropvolgende read controleert de volledige write/read round trip.
4.4. Wat mijn Claude Desktop-run liet zien
De volgende screenshots komen uit dezelfde run van juni 2025, voordat ik de fixture van het artikel terugbracht naar twee rijen. Ze tonen wat Claude Desktop weergaf nadat het verbinding had gemaakt met mijn server. Het zijn observaties van die run en geen gegarandeerde MCP-output. De server retourneert tool- en resourcedata; Claude kiest een tool en schrijft de uitleg rond het resultaat.
Eerst vroeg ik Claude om het databaseschema te tonen:

Claude had een belangrijk detail verkeerd. Het noemde het systeem een NoSQL- of document store, terwijl database.py SQLite gebruikt en een relationele tabel aanmaakt. De kolom metadata bevat JSON-tekst, maar dat verandert de database-engine niet. De screenshot herinnert eraan dat een plausibele modeluitleg niet hetzelfde is als het toolcontract. Controleer de geretourneerde CREATE TABLE-instructie of de source wanneer dit onderscheid belangrijk is.
Ik vroeg Claude ook om de beschikbare features op te sommen:

De vier keys komen overeen met de uitgebreidere lokale database die in Inspector werd getoond. Labels zoals “user embedding” en “model embedding” zijn interpretaties van Claude op basis van namen en metadata. list_features garandeert zelf alleen de rijen die de implementatie retourneert.
Ten slotte haalde ik product_abc op:

Hier kwamen de vector en metadata uit het toolresultaat. De tekst over similarity, aanbevelingen en clustering kwam van Claude. Dat zijn mogelijke toepassingen voor een embedding, maar de server in deze tutorial slaat alleen vectors op en haalt ze weer op. Nearest-neighbor search wordt niet geïmplementeerd.
5. Troubleshooting
Een paar failure modes die je moet kennen:
-
“Server connection failed”:
- Controleer de logs op
~/Library/Logs/Claude/mcp.logop macOS. - Controleer of de config een absoluut path gebruikt en geen relatief path.
- Controleer of
uvop Claude Desktop’sPATHstaat. Als dat niet zo is, gebruik dan het volledige binary path (which uvtoont waar dit staat).
- Controleer de logs op
-
“Tool execution error”:
- Reproduceer het probleem in Inspector met
uv run mcp dev featurestore_server.py. Inspector toont de raw error, die Claude Desktop meestal onderdrukt. - Controleer of
features.dbnaastdatabase.pywordt aangemaakt. Het path komt uitget_db_path(), dat het relatief aan het script resolveert. Daardoor zou een wijzigende working directory het bestand niet moeten verplaatsen.
- Reproduceer het probleem in Inspector met
6. Conclusie
Dat is alles: een FastMCP-server, een SQLite-backing store en een Claude Desktop-configuratie die naar een uv run-commando verwijst. Dezelfde structuur werkt voor vrijwel alles wat je in een Python-functie kunt verpakken. Vervang de SQLite-calls door een echte feature store, een interne API of een model registry, en de server blijft klein.
Belangrijkste punten
- MCP is een toolgrens, geen reden om elke interne functie beschikbaar te maken.
- Houd servertools klein, getypeerd en eenvoudig testbaar zonder een LLM.
- Gebruik uv zodat de omgeving van de tutorial vanaf nul kan worden gerebuild.
- Behandel de MCP-server als production code zodra een agent hem kan aanroepen.
Referenties
- Historische companion repository (bevat de huidige inline code niet)
- Introductie tot MCP
- MCP Python SDK
- Claude Desktop
- uv