MCP Server Tutorial: Build with Python, uv, and FastMCP
Python developers can turn a local feature-store workflow into a version-pinned FastMCP server for Claude Desktop, using uv to create the environment, expose tools and a resource, and verify the server locally before the desktop connection.
What is an MCP server?
The Model Context Protocol (MCP) is an open protocol for connecting AI applications to external systems. It replaces a separate custom integration for every tool with one set of conventions.
In this tutorial we’ll build a FeatureStoreLite MCP server. It sits between an LLM and a feature store, meaning a database of precomputed ML features. The server exposes tools for querying and writing feature vectors keyed by user, product, or document.
Why build this?
Debugging a feature pipeline usually means dropping into SQL or writing a throwaway script to check a value. With the server running, you ask Claude instead: “What is the feature vector for user_123?” or “Show me the metadata for product_abc.”
Why use uv?
We’ll use uv to install packages, resolve dependencies, and manage the virtual environment. The Claude Desktop configuration will run this project from its own directory with --locked, so it uses the mcp[cli] dependency declared here and the versions recorded in uv.lock.
Architecture overview
The four pieces and how they fit together:
- The user asks a question in natural language.
- Claude Desktop is the MCP host. It creates one MCP client for this server and manages the connection.
- Our
FastMCPserver exposesget_featureandstore_featureas MCP tools. - SQLite is the backing store for the feature vectors.
The host can make the discovered tools available to Claude. Claude may request a tool call, but the per-server MCP client sends the protocol messages.
1. Setup and installation
1.1. Install uv
If you don’t already have uv, install it. The rest of the tutorial assumes it’s on your PATH.
# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Or via Homebrew
brew install uv
1.2. Initialize the project
Create a new directory and initialize a Python project. uv init creates a pyproject.toml for you.
# 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"
This tutorial uses the MCP Python SDK v1 API. The official v1 SDK documentation instructs v1 users to pin mcp>=1.28,<2, so keep the <2 upper bound until you migrate the code. uv.lock records the complete resolved environment after this command.
The inline code in this article is canonical. The companion repository linked in the references is a historical version. It does not reproduce the current dependency pin or the current vector validation.
2. Building the server
Two files, split by concern:
database.pyhandles SQLite operations.featurestore_server.pydefines the MCP server.
2.1. The database layer (database.py)
This module owns the SQLite connection and a couple of helpers. We seed it with two example rows so the server has something to return on the first query.
Create 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!")
Initialize the database:
uv run python database.py
2.2. The MCP server (featurestore_server.py)
FastMCP does most of the work. Decorate a plain Python function and it gets registered as an MCP tool or resource. The docstring becomes the description the LLM sees. Write it for that reader.
Create featurestore_server.py:
# 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()
Python’s JSON parser accepts NaN and infinities by default even though they are outside the JSON number grammar. The parse_constant callback rejects those spellings, and math.isfinite checks every vector element before SQLite receives the row. Metadata follows one contract: it must be a JSON object string, and the server parses and normalizes it before insertion. See the Python JSON interoperability notes.
3. Testing with MCP Inspector
Before wiring this into Claude, sanity-check the server with the MCP Inspector. It’s a small web UI for calling tools and reading resources directly.
uv run mcp dev featurestore_server.py
The command starts the server under the MCP Inspector over stdio. Use the browser URL that the command prints. The Inspector UI port is implementation-dependent, so do not assume a fixed port.
This is what my original June 2025 run looked like in MCP Inspector v0.14.0. The screenshot records the Inspector’s stdio configuration and a successful connection. Its unpinned --with mcp argument is historical; use the pinned v1 command from this article when you reproduce the tutorial now.

Call get_feature with key="user_123". If it returns the JSON for the seed row, the server is working.
This checks the local protocol path, tool registration, database lookup, and JSON output. It does not validate vector search quality. For production retrieval, add tests with representative queries, expected nearest-neighbor results, distance thresholds, metadata filters, and an evaluation set that matches your workload.
The same run exposed all three tools and the schema://main resource. These two screenshots are useful checks of discovery: the Inspector found the functions and read the SQL schema that the server returned.


The tool screenshot contains four rows: user_123, product_abc, doc_guide_001, and recommendation_engine. That was my richer local database on June 10, 2025. Its list_features implementation returned objects with key and created_at fields. The current function returns only a JSON array of key strings and seeds only the first two rows. Do not compare the historical output row-for-row with the current fixture.
4. Connecting to Claude Desktop
Once the Inspector confirms the server works, register it with Claude Desktop.
4.1. Configure Claude
Edit your Claude Desktop configuration file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%/Claude/claude_desktop_config.json
Add your server to the mcpServers object:
{
"mcpServers": {
"featurestore": {
"command": "uv",
"args": [
"run",
"--directory",
"/ABSOLUTE/PATH/TO/mcp-featurestore",
"--locked",
"mcp",
"run",
"/ABSOLUTE/PATH/TO/mcp-featurestore/featurestore_server.py"
]
}
}
}
Important: use absolute paths for both the project directory and
featurestore_server.py. Claude Desktop starts the server as a separate process.uv rundiscovers a project from its working directory for a command such asmcp, so--directoryselects this project’spyproject.tomlanduv.lock;--lockedthen fails instead of changing that lockfile. Theuv addcommand in step 1.2 has already declaredmcp[cli]in the project.
4.2. How the interaction works
Here’s what runs end to end when Claude needs a feature lookup:
- Claude Desktop starts a dedicated MCP client for the server and discovers its tools.
- The host makes the tool descriptions available to Claude.
- Claude can request a tool call when the question needs feature-store data.
- The MCP client sends that request to the server.
- The server runs the Python function and returns the result to the host.
- The host can provide the result to Claude for the final answer.
This host-client-server separation follows the MCP architecture specification. Resources also provide context to the host application. MCP does not require the host to pass every resource to the model.
4.3. Example queries
Restart Claude Desktop and try a few prompts:
-
“List all available features.” The deterministic result contains the two seeded keys:
user_123andproduct_abc. -
“Get the feature vector for user_123.” The response contains the vector and the
premiummetadata fromdatabase.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.” Both arguments must contain valid JSON. The write succeeds only after the server validates the vector and metadata. The follow-up read checks the complete write/read round trip.
4.4. What my Claude Desktop run showed
The next screenshots are from the same June 2025 run, before I reduced the article’s fixture to two rows. They show what Claude Desktop displayed after it connected to my server. They are observations of that run, not guaranteed MCP output. The server returns tool and resource data; Claude chooses a tool and writes the explanation around the result.
First I asked Claude to show the database schema:

Claude got an important detail wrong. It called the system a NoSQL or document store even though database.py uses SQLite and creates a relational table. The metadata column contains JSON text, but that does not change the database engine. The screenshot is a good reminder that a plausible model explanation is not the tool contract. Check the returned CREATE TABLE statement or the source when the distinction matters.
I also asked Claude to list the available features:

The four keys match the richer local database shown in Inspector. Labels such as “user embedding” and “model embedding” are Claude’s interpretation of names and metadata. list_features itself only guarantees the rows that its implementation returns.
Finally, I retrieved product_abc:

Here the vector and metadata came from the tool result. The prose about similarity, recommendations, and clustering came from Claude. Those are possible uses for an embedding, but this tutorial’s server only stores and retrieves vectors. It does not implement nearest-neighbor search.
5. Troubleshooting
A few failure modes worth knowing about:
-
“Server connection failed”:
- Check the logs at
~/Library/Logs/Claude/mcp.logon macOS. - Confirm the config uses an absolute path, not a relative one.
- Confirm
uvis on Claude Desktop’sPATH. If it isn’t, point at the full binary path (which uvwill tell you where it lives).
- Check the logs at
-
“Tool execution error”:
- Reproduce it in the Inspector with
uv run mcp dev featurestore_server.py. The Inspector shows the raw error, which Claude Desktop usually swallows. - Check that
features.dbis being created next todatabase.py. The path comes fromget_db_path(), which resolves it relative to the script, so a shifting working directory should not move the file.
- Reproduce it in the Inspector with
6. Conclusion
That’s the whole thing: a FastMCP server, a SQLite backing store, and a Claude Desktop config that points at a uv run command. The same shape works for most things you can wrap in a Python function. Swap the SQLite calls for a real feature store, an internal API, or a model registry, and the server stays small.
Key takeaways
- MCP is a tool boundary, not a reason to expose every internal function.
- Keep server tools small, typed, and easy to test without an LLM.
- Use uv so the tutorial environment can be rebuilt from scratch.
- Treat the MCP server as production code once an agent can call it.
References
- Historical companion repository (does not reproduce the current inline code)
- Introduction to MCP
- MCP Python SDK
- Claude Desktop
- uv