Tutorial de un servidor MCP: construirlo con Python, uv y FastMCP

Traducción automática Este artículo se tradujo automáticamente a partir de la versión original en inglés.

Los desarrolladores de Python pueden convertir un workflow local de feature store en un servidor FastMCP con versiones fijadas para Claude Desktop. Para ello, usan uv para crear el entorno, exponer herramientas y un recurso, y verificar el servidor localmente antes de establecer la conexión con el escritorio.

En resumen: Un servidor FastMCP pequeño basta para exponer una herramienta local útil a un agente. Usa uv para conseguir una configuración reproducible, mantén estrecho el límite del servidor, valida la forma del vector en el límite de la herramienta y prueba la herramienta MCP antes de conectarla a un modelo.


¿Qué es un servidor MCP?

El Model Context Protocol (MCP) es un protocolo abierto para conectar aplicaciones de AI con sistemas externos. Sustituye una integración personalizada independiente para cada herramienta por un conjunto de convenciones.

En este tutorial construiremos un servidor MCP FeatureStoreLite. Se sitúa entre un LLM y un feature store, es decir, una base de datos de features de ML precalculadas. El servidor expone herramientas para consultar y escribir vectores de features identificados por usuario, producto o documento.

¿Por qué construirlo?

Depurar un pipeline de features suele implicar recurrir a SQL o escribir un script desechable para comprobar un valor. Con el servidor en ejecución, en su lugar puedes preguntar a Claude: «¿Cuál es el vector de features de user_123?» o «Enséñame los metadatos de product_abc».

¿Por qué usar uv?

Usaremos uv para instalar paquetes, resolver dependencias y gestionar el entorno virtual. La configuración de Claude Desktop ejecutará este proyecto desde su propio directorio con --locked, por lo que usará la dependencia mcp[cli] declarada aquí y las versiones registradas en uv.lock.

Descripción general de la arquitectura

Estas son las cuatro piezas y cómo encajan:

Arquitectura de FeatureStoreLite, desde una solicitud del usuario hasta el host MCP de Claude Desktop y el cliente por servidor, pasando por el servidor FastMCP y el almacén SQLiteArquitectura de FeatureStoreLite, desde una solicitud del usuario hasta el host MCP de Claude Desktop y el cliente por servidor, pasando por el servidor FastMCP y el almacén SQLite

  1. El usuario formula una pregunta en lenguaje natural.
  2. Claude Desktop es el host MCP. Crea un cliente MCP para este servidor y gestiona la conexión.
  3. Nuestro servidor FastMCP expone get_feature y store_feature como herramientas MCP.
  4. SQLite es el almacén subyacente de los vectores de features.

El host puede poner las herramientas descubiertas a disposición de Claude. Claude puede solicitar un tool call, pero el cliente MCP por servidor envía los mensajes del protocolo.


1. Configuración e instalación

1.1. Instalar uv

Si aún no tienes uv, instálalo. El resto del tutorial presupone que está en tu PATH.

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

# Or via Homebrew
brew install uv

1.2. Inicializar el proyecto

Crea un directorio nuevo e inicializa un proyecto de Python. uv init crea un pyproject.toml por ti.

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

Este tutorial usa la API v1 del SDK de MCP para Python. La documentación oficial del SDK v1 indica a los usuarios de v1 que fijen mcp>=1.28,<2, así que mantén el límite superior de <2 hasta migrar el código. uv.lock registra el entorno completo resuelto después de este comando.

El código inline de este artículo es el canónico. El repositorio complementario enlazado en las referencias corresponde a una versión histórica. No reproduce la fijación de dependencias actual ni la validación de vectores actual.


2. Construir el servidor

Dos archivos, separados por responsabilidad:

  1. database.py gestiona las operaciones de SQLite.
  2. featurestore_server.py define el servidor MCP.

2.1. La capa de base de datos (database.py)

Este módulo es propietario de la conexión a SQLite y de un par de funciones auxiliares. Lo inicializamos con dos filas de ejemplo para que el servidor tenga algo que devolver en la primera consulta.

Crea 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!")

Inicializa la base de datos:

uv run python database.py

2.2. El servidor MCP (featurestore_server.py)

FastMCP hace la mayor parte del trabajo. Decora una función de Python normal y esta queda registrada como herramienta o recurso MCP. El docstring se convierte en la descripción que ve el LLM. Redáctalo pensando en ese lector.

Crea 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()

El parser JSON de Python acepta NaN y los infinitos de forma predeterminada, aunque estén fuera de la gramática de números de JSON. El callback parse_constant rechaza esas representaciones, y math.isfinite comprueba cada elemento del vector antes de que SQLite reciba la fila. Los metadatos siguen un único contrato: deben ser una cadena con un objeto JSON, y el servidor los parsea y normaliza antes de insertarlos. Consulta las notas de interoperabilidad de JSON en Python.


3. Probar con MCP Inspector

Antes de conectarlo a Claude, comprueba el servidor con MCP Inspector. Es una pequeña interfaz web para invocar herramientas y leer recursos directamente.

uv run mcp dev featurestore_server.py

El comando inicia el servidor bajo MCP Inspector mediante stdio. Usa la URL del navegador que imprime el comando. El puerto de la interfaz de Inspector depende de la implementación, así que no des por hecho que sea fijo.

Así fue mi ejecución original de junio de 2025 en MCP Inspector v0.14.0. La captura registra la configuración stdio de Inspector y una conexión correcta. Su argumento --with mcp, sin fijar, es histórico; cuando reproduzcas ahora el tutorial, usa el comando v1 fijado de este artículo.

MCP Inspector v0.14.0 conectado a FeatureStoreLite mediante stdio con uv

Invoca get_feature con key="user_123". Si devuelve el JSON de la fila inicial, el servidor funciona. Esto comprueba la ruta de protocolo local, el registro de herramientas, la consulta a la base de datos y la salida JSON. No valida la calidad de la búsqueda vectorial. Para retrieval en producción, añade pruebas con consultas representativas, resultados de nearest neighbors esperados, umbrales de distancia, filtros de metadatos y un conjunto de evaluación que corresponda a tu workload.

La misma ejecución expuso las tres herramientas y el recurso schema://main. Estas dos capturas son comprobaciones útiles del discovery: Inspector encontró las funciones y leyó el esquema SQL que devolvió el servidor.

MCP Inspector mostrando store_feature, get_feature, list_features y una llamada correcta a list_features

MCP Inspector mostrando el recurso get_schema y la sentencia CREATE TABLE de SQLite devuelta

La captura de la herramienta contiene cuatro filas: user_123, product_abc, doc_guide_001 y recommendation_engine. Esa era mi base de datos local más completa del 10 de junio de 2025. Su implementación de list_features devolvía objetos con los campos key y created_at. La función actual devuelve únicamente un array JSON de cadenas de claves y solo inicializa las dos primeras filas. No compares fila por fila la salida histórica con el fixture actual.


4. Conectar con Claude Desktop

Una vez que Inspector confirme que el servidor funciona, regístralo en Claude Desktop.

4.1. Configurar Claude

Edita el archivo de configuración de Claude Desktop:

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

Añade tu servidor al objeto mcpServers:

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

Importante: usa rutas absolutas tanto para el directorio del proyecto como para featurestore_server.py. Claude Desktop inicia el servidor como un proceso independiente. uv run descubre un proyecto a partir de su directorio de trabajo para un comando como mcp, por lo que --directory selecciona el pyproject.toml y el uv.lock de este proyecto; --locked falla en lugar de modificar ese lockfile. El comando uv add del paso 1.2 ya ha declarado mcp[cli] en el proyecto.

4.2. Cómo funciona la interacción

Esto es lo que ocurre de extremo a extremo cuando Claude necesita consultar una feature:

Consulta de FeatureStoreLite en seis pasos, desde el discovery de herramientas MCP hasta tools/call, el acceso a SQLite y la respuesta final de ClaudeConsulta de FeatureStoreLite en seis pasos, desde el discovery de herramientas MCP hasta tools/call, el acceso a SQLite y la respuesta final de Claude

  1. Claude Desktop inicia un cliente MCP dedicado para el servidor y descubre sus herramientas.
  2. El host pone las descripciones de las herramientas a disposición de Claude.
  3. Claude puede solicitar un tool call cuando la pregunta requiere datos del feature store.
  4. El cliente MCP envía esa solicitud al servidor.
  5. El servidor ejecuta la función de Python y devuelve el resultado al host.
  6. El host puede proporcionar el resultado a Claude para la respuesta final.

Esta separación entre host, cliente y servidor sigue la especificación de arquitectura de MCP. Los recursos también proporcionan contexto a la aplicación host. MCP no obliga al host a pasar todos los recursos al modelo.

4.3. Ejemplos de consultas

Reinicia Claude Desktop y prueba algunos prompts:

  1. «Enumera todas las features disponibles». El resultado determinista contiene las dos claves iniciales: user_123 y product_abc.

  2. «Obtén el vector de features de user_123». La respuesta contiene el vector y los metadatos premium de database.py.

  3. «Almacena una feature nueva para new_item con la cadena JSON del vector [0.5, 0.5] y la cadena JSON de metadatos {"type": "test"}. Después recupera new_item y muestra el vector y los metadatos almacenados». Ambos argumentos deben contener JSON válido. La escritura solo se realiza después de que el servidor valide el vector y los metadatos. La lectura posterior comprueba el ciclo completo de escritura y lectura.

4.4. Qué mostró mi ejecución de Claude Desktop

Las capturas siguientes corresponden a la misma ejecución de junio de 2025, antes de que redujera a dos filas el fixture del artículo. Muestran lo que Claude Desktop enseñó después de conectarse a mi servidor. Son observaciones de esa ejecución, no una salida MCP garantizada. El servidor devuelve datos de herramientas y recursos; Claude elige una herramienta y redacta la explicación alrededor del resultado.

Primero pedí a Claude que mostrara el esquema de la base de datos:

Claude Desktop respondiendo a una solicitud para mostrar el esquema de la base de datos de FeatureStoreLite

Claude se equivocó en un detalle importante. Describió el sistema como un almacén NoSQL o documental, aunque database.py usa SQLite y crea una tabla relacional. La columna metadata contiene texto JSON, pero eso no cambia el motor de base de datos. La captura recuerda que una explicación verosímil del modelo no es el contrato de la herramienta. Comprueba la sentencia CREATE TABLE devuelta o el código fuente cuando la distinción sea importante.

También pedí a Claude que enumerara las features disponibles:

Claude Desktop invocando list_features y mostrando las cuatro filas de la base de datos del autor de junio de 2025

Las cuatro claves coinciden con la base de datos local más completa mostrada en Inspector. Etiquetas como «user embedding» y «model embedding» son interpretaciones de Claude basadas en los nombres y los metadatos. list_features solo garantiza las filas que devuelve su implementación.

Por último, recuperé product_abc:

Claude Desktop invocando get_feature para product_abc y mostrando su vector y sus metadatos

En este caso, el vector y los metadatos procedían del resultado de la herramienta. El texto sobre similitud, recomendaciones y clustering lo generó Claude. Son posibles usos de un embedding, pero el servidor de este tutorial solo almacena y recupera vectores. No implementa una búsqueda de nearest neighbors.


5. Solución de problemas

Conviene conocer algunos modos de fallo:

  • «Server connection failed»:

    • Comprueba los logs en ~/Library/Logs/Claude/mcp.log en macOS.
    • Confirma que la configuración usa una ruta absoluta, no una relativa.
    • Confirma que uv está en el PATH de Claude Desktop. Si no lo está, indica la ruta completa al binario (which uv te dirá dónde se encuentra).
  • «Tool execution error»:

    • Reprodúcelo en Inspector con uv run mcp dev featurestore_server.py. Inspector muestra el error sin procesar, que Claude Desktop normalmente oculta.
    • Comprueba que features.db se crea junto a database.py. La ruta procede de get_db_path(), que la resuelve relativa al script, por lo que un directorio de trabajo cambiante no debería mover el archivo.

6. Conclusión

Eso es todo: un servidor FastMCP, un almacén SQLite y una configuración de Claude Desktop que apunta a un comando uv run. El mismo patrón funciona para casi cualquier cosa que puedas envolver en una función de Python. Sustituye las llamadas a SQLite por un feature store real, una API interna o un registro de modelos, y el servidor seguirá siendo pequeño.

Puntos clave

  1. MCP es un límite de herramientas, no una razón para exponer todas las funciones internas.
  2. Mantén las herramientas del servidor pequeñas, tipadas y fáciles de probar sin un LLM.
  3. Usa uv para que el entorno del tutorial pueda reconstruirse desde cero.
  4. Trata el servidor MCP como código de producción en cuanto un agente pueda invocarlo.

Referencias