Servidor MCP 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.

Este tutorial de servidor MCP construye un pequeño servidor de feature store con FastMCP, lo ejecuta con uv y lo conecta a Claude Desktop. Está pensado para desarrolladores de Python que quieren exponer a un host MCP un flujo de trabajo con una base de datos local. Al terminar tendrás un entorno con el SDK v1 fijado, un servidor con tres herramientas y un recurso, y una comprobación local que ejecutar antes de conectar Claude Desktop.


¿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 a medida para cada herramienta por un único conjunto de convenciones.

En este tutorial construiremos un servidor MCP llamado 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 indexados por usuario, producto o documento.

¿Por qué construirlo?

Depurar un pipeline de features suele significar abrir SQL o escribir un script desechable para comprobar un valor. Con el servidor en marcha, se lo preguntas a Claude: “¿Cuál es el vector de features de user_123?” o “Muéstrame 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, de modo que use la dependencia mcp[cli] declarada aquí y las versiones registradas en uv.lock.

Herramientas, recursos y prompts

Un servidor MCP puede ofrecer tres tipos de elementos. La especificación de MCP los distingue según quién decide usar cada uno:

PrimitivaQuién la activaEn este servidor
HerramientaEl modelo decide llamarlaget_feature, store_feature, list_features
RecursoEl host decide cómo incluirlo en el contextoschema://main
PromptEl usuario lo elige, como una plantilla de mensaje guardadaNo se usa aquí

Las herramientas permiten al modelo solicitar una acción. Los recursos aportan contexto a través del host; la sección 4.4 muestra por qué exponer un recurso no garantiza que una respuesta lo use.

Visión general de la arquitectura

El diagrama muestra qué partes se ejecutan en tu máquina y qué datos van al modelo:

Arquitectura de FeatureStoreLite: el modelo Claude, en el proveedor del modelo, recibe tu pregunta, la lista de herramientas y los tool results, que son filas de features.db; en tu máquina, Claude Desktop y su cliente MCP hablan por stdin y stdout con el proceso del servidor FeatureStoreLite, que expone tres herramientas y el recurso schema://main y lee y escribe la tabla SQLite con las filas user_123 y product_abcArquitectura de FeatureStoreLite: el modelo Claude, en el proveedor del modelo, recibe tu pregunta, la lista de herramientas y los tool results, que son filas de features.db; en tu máquina, Claude Desktop y su cliente MCP hablan por stdin y stdout con el proceso del servidor FeatureStoreLite, que expone tres herramientas y el recurso schema://main y lee y escribe la tabla SQLite con las filas user_123 y product_abc

  1. El usuario hace una pregunta en lenguaje natural.
  2. Claude Desktop es el host MCP. Envía la conversación al modelo Claude, crea un cliente MCP para este servidor y gestiona la conexión.
  3. Nuestro servidor FastMCP expone tres herramientas (get_feature, store_feature, list_features) y un recurso (schema://main).
  4. SQLite es el almacén de respaldo de los vectores de features.

El host pasa la lista de herramientas a Claude. Claude puede solicitar un tool call, pero es el cliente MCP de cada servidor quien envía los mensajes del protocolo. El servidor y SQLite se ejecutan en local, pero los tool results llegan al modelo como parte de la conversación. Expón solo datos que también pegarías en el chat.


1. Preparación e instalación

1.1. Instalar uv

Si aún no tienes uv, instálalo. El resto del tutorial da por hecho 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 Python de MCP. La documentación oficial del SDK v1 indica a quienes usan la v1 que fijen mcp>=1.28,<2, así que mantén el límite superior <2 hasta que migres el código. uv.lock registra el entorno completo resuelto tras este comando.

El código usa el FastMCP que se incluye en el SDK oficial (mcp.server.fastmcp). El paquete independiente fastmcp es otro proyecto con una API distinta, así que no mezcles su documentación con este código.


2. Construir el servidor

Dos archivos, separados por responsabilidad:

  1. database.py se encarga de las operaciones con SQLite.
  2. featurestore_server.py define el servidor MCP.

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

Este módulo gestiona la conexión con SQLite y un par de funciones auxiliares. Lo poblamos 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. Decoras una función normal de Python y se registra como herramienta o recurso de MCP. FastMCP genera el esquema de entrada de cada herramienta a partir de las anotaciones de tipo y usa el docstring como descripción de la herramienta. El modelo ve ambos, así que escribe el docstring para el modelo: indica qué hace la herramienta y qué forma tiene cada argumento.

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

Cuatro detalles de este código deciden qué envía el modelo y qué recibe:

  • vector tipado. El tool schema pide un array no vacío de números. Con el SDK 1.30, Pydantic también convierte booleanos y cadenas numéricas en floats: [true, false] pasa a ser [1.0, 0.0], y ["1.5", "2"] pasa a ser [1.5, 2.0]. Después comprueba que la lista no esté vacía y que todos los valores sean finitos antes de ejecutar la función. Si un modelo envía el vector como una cadena JSON como "[1, NaN]", FastMCP analiza primero la cadena. allow_inf_nan=False rechaza entonces NaN y los infinitos, que el analizador JSON de Python acepta por defecto.
  • metadata tipado. El esquema acepta un objeto JSON, null o un valor omitido. allow_nan=False evita también que NaN llegue a los metadatos almacenados.
  • Errores lanzados. get_feature lanza ValueError si falta la clave. Una excepción lanzada llega al cliente como un error de herramienta con isError: true; una cadena devuelta parece un resultado correcto.
  • Sobrescrituras. store_feature usa INSERT OR REPLACE, así que reemplaza una clave existente. El docstring lo indica, de modo que el modelo lo sabe antes de llamar a la herramienta.

3. Pruebas con MCP Inspector

Antes de conectarlo a Claude, comprueba el servidor con MCP Inspector. Es una pequeña interfaz web que se conecta al servidor igual que lo hace un host. En ella puedes explorar los recursos del servidor, llamar a cada herramienta con tus propios argumentos y ver cada petición y respuesta.

El SDK inicia Inspector mediante npx, así que este paso también necesita Node.js y npm con npx en tu PATH. Inspector 2.10.1 requiere Node.js 22.19.0 o posterior. Comprueba los comandos instalados y luego inicia Inspector:

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

El comando inicia el servidor bajo MCP Inspector por 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 un puerto fijo.

Llama a get_feature con key="user_123". Si devuelve el JSON de la fila inicial, el servidor funciona. Después llámala con una clave que no exista: el resultado es un error de herramienta con isError: true. Esto comprueba la ruta local del protocolo, el registro de herramientas, la consulta a la base de datos y la ruta de error. No comprueba cómo elige Claude las herramientas; eso lo comprueba la sección 4.

Inspector lee el esquema SQL del recurso schema://main:

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


4. Conectar con Claude Desktop

Cuando 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 aparte. Para un comando como mcp, uv run descubre el proyecto a partir de su directorio de trabajo, así que --directory selecciona el pyproject.toml y el uv.lock de este proyecto; --locked falla en lugar de modificar ese archivo de bloqueo. El comando uv add del paso 1.2 ya ha declarado mcp[cli] en el proyecto.

El SDK también tiene un atajo: uv run mcp install featurestore_server.py escribe por ti la entrada de Claude Desktop. En mcp 1.30 esa entrada ejecuta uv run --frozen --with mcp[cli] mcp run /ABSOLUTE/PATH/featurestore_server.py. No incluye --directory, así que no usa el uv.lock de este proyecto, y --with mcp[cli] no tiene el límite <2. Úsalo para una prueba rápida; la configuración manual de arriba es la reproducible.

4.2. Cómo funciona la interacción

Esto es lo que se ejecuta de principio a fin cuando Claude necesita consultar una feature. Los pasos 1 y 2 se ejecutan una vez, cuando Claude Desktop inicia el servidor. Los pasos 3 a 6 se ejecutan con cada pregunta que necesita datos de features.

Diagrama de secuencia de una consulta de user_123 entre el modelo Claude, Claude Desktop, el servidor FeatureStoreLite y features.db: tools/list y las descripciones de las herramientas al inicio y, para cada pregunta, la petición get_feature del modelo, un mensaje tools/call, el SELECT de SQLite, el resultado JSON y la respuesta final, numerados del 1 al 6 como en la lista de abajoDiagrama de secuencia de una consulta de user_123 entre el modelo Claude, Claude Desktop, el servidor FeatureStoreLite y features.db: tools/list y las descripciones de las herramientas al inicio y, para cada pregunta, la petición get_feature del modelo, un mensaje tools/call, el SELECT de SQLite, el resultado JSON y la respuesta final, numerados del 1 al 6 como en la lista de abajo

  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 necesita datos del feature store.
  4. El cliente MCP envía esa petición al servidor.
  5. El servidor ejecuta la función de Python y devuelve el resultado al host.
  6. El host puede entregar el resultado a Claude para la respuesta final.

Esta separación entre host, cliente y servidor sigue la especificación de la arquitectura de MCP.

4.3. Consultas de ejemplo

Reinicia Claude Desktop y prueba algunos prompts:

  1. “Lista 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. “Guarda una nueva feature para new_item con el vector [0.5, 0.5] y los metadatos {"type": "test"}. Después recupera new_item y muestra el vector y los metadatos almacenados.” El vector validado no debe estar vacío y solo debe contener floats finitos después de la conversión. Los metadatos pueden ser un objeto JSON, null o estar omitidos. La lectura posterior comprueba el ciclo completo de escritura y lectura.

  4. “Obtén el vector de features de user_999.” get_feature lanza un error por la clave inexistente. Claude debería decir que la clave no existe y puede llamar a list_features para mostrar las claves válidas.

4.4. Cómo leer las respuestas de Claude

El servidor devuelve datos de herramientas y recursos. Claude elige la herramienta y redacta la explicación en torno al resultado.

Esta captura registra una ejecución de junio de 2025 con una versión anterior del ejemplo. Pedí a Claude que mostrara el esquema de la base de datos:

Claude Desktop ofrece un esquema conceptual incorrecto de FeatureStoreLite en la ejecución de junio de 2025

Claude describió el sistema como un almacén NoSQL o de documentos y dio un esquema conceptual, pero database.py crea una tabla relacional de SQLite. La respuesta mostrada no es la sentencia CREATE TABLE que devuelve schema://main. La captura no registra si Claude Desktop leyó ese recurso ni qué contexto recibió Claude, por lo que no demuestra la causa del error.

Los recursos los controla la aplicación: el host decide cómo incluirlos en el contexto del modelo, mediante selección del usuario o inclusión automática. MCP no exige que el host pase todos los recursos al modelo. Si el modelo debe solicitar el esquema mediante un tool call, exponlo también como herramienta.

Una explicación verosímil del modelo no es lo mismo que lo que devolvió la herramienta. Cuando el detalle importe, comprueba el tool result o el código fuente.

4.5. Otros hosts MCP

El servidor no depende de Claude Desktop. Cualquier host que inicie servidores stdio en la misma máquina puede ejecutar el mismo comando uv. Claude Desktop usa el JSON de la sección 4.1; los otros dos son:

Un host en otra máquina no puede iniciar un proceso stdio en la tuya. Para un servidor que comparten varias personas, ejecútalo sobre Streamable HTTP con mcp.run(transport="streamable-http") y añade autenticación delante.


5. Solución de problemas

Averigua qué parte ha fallado antes de cambiar el código: los errores de arranque están en los logs, los errores de las herramientas se repiten en Inspector sin un modelo, y una respuesta incorrecta exige comparar el tool result con el texto de Claude.

  • “Server connection failed”:

    • Revisa los logs en ~/Library/Logs/Claude/ en macOS o en %APPDATA%\Claude\logs en Windows. mcp.log registra los eventos de conexión. mcp-server-featurestore.log, en la misma carpeta, contiene el stderr del propio servidor, así que un traceback de arranque aparece ahí (logs de Claude Desktop).
    • 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 del binario (which uv te dirá dónde está).
    • En stdio, stdout transporta los mensajes del protocolo. Escribe los logs en stderr; un print() en el servidor corrompe ese flujo y puede romper la conexión.
  • “Tool execution error”:

    • Reprodúcelo en Inspector con uv run mcp dev featurestore_server.py. Inspector muestra el mensaje de error y los argumentos que lo causaron.
    • Comprueba que features.db se crea junto a database.py. La ruta sale de get_db_path(), que la resuelve respecto al script, así que un directorio de trabajo cambiante no debería mover el archivo.
  • La llamada funciona, pero la respuesta es incorrecta:

    • Abre el tool result en el chat y compáralo con el texto de Claude. La sección 4.4 muestra una respuesta del modelo que describió mal la base de datos.

6. Antes de que un agente lo use de verdad

  • store_feature sobrescribe cualquier clave, incluidas las filas iniciales. Mantén activa la aprobación por llamada para las herramientas de escritura en el host, o muévelas a un servidor aparte que actives solo cuando lo necesites.
  • Los tool results llegan al proveedor del modelo como parte de la conversación. No devuelvas datos que deban quedarse en la máquina.
  • Un servidor que comparten varias personas necesita Streamable HTTP y autenticación en lugar de stdio (sección 4.5).

7. 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. La misma estructura sirve para casi cualquier cosa que puedas envolver en una función de Python. Cambia las llamadas a SQLite por un feature store real, una API interna o un registro de modelos, y el servidor sigue siendo pequeño.

Referencias