Blogs / Construir un servidor MCP en Python

Construir un servidor MCP en Python

Publicado
5 de octubre de 2026
Autor
Faizan Nadeem
Etiquetas
MCP Python AI Agent Tools Backend Development
A dark workshop filled with hand tools hanging on a pegboard and wood pieces on a workbench
Photo by Minh Đức on Unsplash

La mayoría de los tutoriales de MCP te muestran un tool de “hello world” — una función, dos argumentos, un print — y se detienen justo donde empiezan las decisiones reales. Un servidor con un solo tool no necesita un diseño; necesita un decorator. El trabajo de verdad comienza cuando tienes más de una cosa que exponer y debes decidir qué es un “tool” versus qué debería ser un “resource”, si un cliente LLM debería tener permiso para llamar a todo en tu servidor sin autenticación, y cuánta de tu superficie de API interna estás dispuesto a entregarle a un modelo que decide por sí mismo qué llamar y cuándo.

Este artículo construye un servidor MCP desde cero en Python, cubriendo las tres primitivas que define el protocolo — tools, resources y prompts — y trata las decisiones de diseño con la misma seriedad que el código. Si estás construyendo este servidor para que quede detrás de un agente basado en LangGraph, el punto en el que el flujo de control de un agente se vuelve suficientemente complejo como para necesitar un grafo es en sí mismo una decisión que vale la pena entender antes de empezar a decidir cuánta de tu API exponer a través de él.

Aprenderás:

  • La diferencia entre un tool, un resource y un prompt en MCP, y cuándo usar cada uno
  • Cómo construir los tres usando la interfaz FastMCP del Python SDK
  • Cómo estructurar entradas y salidas de tools para que un LLM pueda usarlos de manera confiable
  • La decisión de superficie de tools: qué exponer, qué mantener interno, y por qué “exponer todo” es un error
  • Una configuración de autenticación práctica para un servidor que no solo corre en localhost
  • Cómo probar tu servidor localmente con el MCP Inspector antes de conectar un cliente real

Tabla de contenidos

  1. Las tres primitivas
  2. Configurar el Python SDK
  3. Construir tools
  4. Construir resources
  5. Construir prompts
  6. Manejar errores de forma que el modelo pueda usarlos
  7. Poniéndolo todo junto
  8. Versionar tools sin romper clientes
  9. La decisión de superficie de tools
  10. Autenticación antes de hacer el deploy
  11. Probar localmente con el MCP Inspector
  12. Conclusión

Las tres primitivas

MCP define tres maneras distintas en las que un servidor puede ofrecer capacidades a un cliente, y confundirlas es el error de diseño más común en un primer servidor.

Los tools son acciones — funciones que el modelo puede decidir llamar, con entradas tipadas y un valor de retorno. Un tool tiene efectos secundarios o hace trabajo: enviar un email, consultar una base de datos, ejecutar un cálculo. El modelo elige cuándo llamar a un tool y con qué argumentos, basándose en su descripción.

Los resources son datos que el cliente puede leer, identificados por un URI, que el modelo no tiene que “llamar” tanto como “consultar”. Piensa en un resource como algo más parecido a un endpoint GET que a una llamada a función — es para contenido que debería estar disponible como contexto, no algo sobre lo que el modelo necesita razonar para invocarlo con parámetros específicos.

Los prompts son plantillas de mensajes reutilizables y parametrizadas que el cliente puede mostrar a un usuario — piensa en un menú de slash-commands, no en algo que el modelo llama de forma autónoma. Un prompt como /summarize-ticket toma un ID de ticket y se expande en una plantilla de prompt completa; el usuario (o la UI del cliente) lo selecciona, el modelo no decide invocarlo por sí solo.

Hacer bien esta división importa porque cambia quién tiene el control. Un tool es iniciado por el modelo. Un resource es típicamente iniciado por el cliente (el cliente decide qué contexto adjuntar). Un prompt es iniciado por el usuario. Construir un “resource” que en realidad necesita argumentos y tiene efectos secundarios es una señal de que debería haber sido un tool.

Configurar el Python SDK

El SDK oficial de Python incluye una clase FastMCP que maneja el plumbing de JSON-RPC, la generación de esquemas a partir de type hints, y la configuración del transporte, así que un servidor mínimo son solo unas pocas líneas.

pip install "mcp[cli]"
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Support Tools")

if __name__ == "__main__":
    mcp.run(transport="stdio")

El transporte stdio es el default correcto para el desarrollo local y para servidores que un cliente lanza como subprocess — es lo que típicamente usa una integración de editor o un agente local. Para un servidor que necesita ser accesible por la red, mcp.run(transport="streamable-http") sirve el mismo servidor sobre HTTP sin cambiar ninguna de las definiciones de tools, resources o prompts de abajo.

Construir tools

Un tool es simplemente una función Python con un decorator @mcp.tool(). El SDK lee tus type hints y docstring para generar el esquema JSON que ve el cliente — esta es también la única información del modelo sobre cuándo y cómo llamarlo, así que trata el docstring como la documentación de API que realmente es.

from pydantic import BaseModel, Field

class RefundResult(BaseModel):
    refund_id: str
    status: str
    amount: float

@mcp.tool()
def issue_refund(
    order_id: str = Field(description="The order ID to refund, e.g. 'ord_8213'"),
    amount: float = Field(description="Refund amount in USD, must be <= the order total"),
    reason: str = Field(description="Short reason for the refund, shown to the customer"),
) -> RefundResult:
    """Issue a refund for a completed order. Only works on orders older than 24 hours."""
    order = get_order(order_id)
    if order.total < amount:
        raise ValueError(f"Refund amount {amount} exceeds order total {order.total}")
    refund = process_refund(order_id, amount, reason)
    return RefundResult(refund_id=refund.id, status=refund.status, amount=amount)

Algunas cosas sobre las que vale la pena ser deliberado:

  • Lanzar excepciones reales para entradas inválidas. El SDK convierte una excepción no capturada en un resultado de error que ve el modelo, que es exactamente lo que quieres — un modelo que recibe un error claro “el monto del reembolso excede el total del pedido” a menudo puede autocorregirse en la siguiente llamada, mientras que un tool que silenciosamente hace algo incorrecto no puede detectarse.
  • Devolver datos estructurados, no un string formateado. Devolver un modelo Pydantic (o un dict) le da al cliente — y a cualquier código downstream — algo que puede parsear de manera confiable, en vez de que un modelo tenga que re-parsear una oración que escribiste para humanos.
  • Mantener cada tool haciendo una sola cosa. Un tool llamado manage_order que toma un parámetro action: str (“refund”, “cancel”, “resend”) empuja una decisión que el protocolo está construido para manejar — qué tool llamar — hacia un string que el modelo tiene que acertar exactamente. Tools separados con nombres separados y específicos son más fáciles de seleccionar correctamente para un modelo y más fáciles de proteger individualmente con auth o un paso de confirmación.

Los tools async funcionan igual — solo declara la función como async def y el SDK lo maneja:

@mcp.tool()
async def lookup_shipment_status(tracking_number: str) -> str:
    """Look up the current status of a shipment by tracking number."""
    async with httpx.AsyncClient() as client:
        resp = await client.get(f"https://carrier.example.com/track/{tracking_number}")
        resp.raise_for_status()
        return resp.json()["status"]

Construir resources

Los resources se registran con una plantilla URI en lugar de ser invocados con argumentos arbitrarios. Un cliente lee un resource por su URI; el servidor lo resuelve a contenido.

@mcp.resource("orders://{order_id}/summary")
def order_summary(order_id: str) -> str:
    """A human-readable summary of an order, for use as context."""
    order = get_order(order_id)
    return (
        f"Order {order.id}: {len(order.items)} items, "
        f"total ${order.total:.2f}, status: {order.status}"
    )

@mcp.resource("config://support-policies")
def support_policies() -> str:
    """Static policy text every support conversation should have as context."""
    return SUPPORT_POLICY_TEXT

El esquema URI (orders://, config://) es completamente tu propia convención — MCP no estandariza qué significan los esquemas, solo requiere que los resources sean direccionables por URI. Usa resources estáticos como config://support-policies para contenido que siempre debería estar disponible como contexto de fondo independientemente de qué trate la conversación, y resources con plantilla como orders://{order_id}/summary para contenido que el cliente obtiene basándose en lo que se está discutiendo.

Construir prompts

Un prompt devuelve uno o más mensajes, opcionalmente parametrizados, pensados para ser seleccionados por un usuario más que invocados por el modelo en medio del razonamiento.

from mcp.server.fastmcp.prompts import base

@mcp.prompt()
def draft_refund_response(order_id: str, tone: str = "empathetic") -> list[base.Message]:
    """Draft a customer-facing message explaining a refund decision."""
    order = get_order(order_id)
    return [
        base.UserMessage(
            f"Write a {tone} message to the customer explaining the refund decision "
            f"for order {order.id}. Order details: {order.items}, total ${order.total:.2f}."
        )
    ]

Un cliente con UI — un cliente de chat, una herramienta interna de soporte — puede mostrar esto como una acción seleccionable (“Redactar respuesta de reembolso”) que se expande en un prompt completamente formado con los datos reales del pedido ya rellenados, en vez de requerir que el usuario escriba todo ese contexto manualmente cada vez.

Manejar errores de forma que el modelo pueda usarlos

Vale la pena separar dos tipos de falla que puede encontrar un tool, porque el SDK los trata diferente y tu código también debería hacerlo. Una excepción no capturada se convierte en un error de llamada a tool que el cliente ve como un fallo — apropiado para bugs reales o estados irrecuperables. Un fallo de validación que el modelo podría plausiblemente corregir — un ID de pedido malformado, un monto fuera de un rango permitido — es mejor devolverlo como un resultado normal y estructurado que describe qué salió mal, porque eso le da al modelo un hecho claro del que razonar en lugar de un objeto de error opaco.

class RefundOutcome(BaseModel):
    success: bool
    refund_id: str | None = None
    error: str | None = None

@mcp.tool()
def issue_refund(order_id: str, amount: float, reason: str) -> RefundOutcome:
    """Issue a refund for a completed order. Returns success=False with an
    explanation if the refund can't be processed, rather than raising."""
    order = get_order(order_id)
    if order is None:
        return RefundOutcome(success=False, error=f"No order found with ID {order_id}")
    if amount > order.total:
        return RefundOutcome(
            success=False,
            error=f"Refund amount {amount} exceeds order total {order.total:.2f}",
        )
    refund = process_refund(order_id, amount, reason)
    return RefundOutcome(success=True, refund_id=refund.id)

Este patrón — un tipo de resultado con un flag success en lugar de una excepción — tiende a producir mejor comportamiento del agente en la práctica, porque el modelo ve el fallo como parte de la conversación normal en lugar de como una interrupción de la interacción. Reserva las excepciones reales para casos donde continuar genuinamente no tiene sentido: una conexión de base de datos caída, una invariante interna violada, algo que ningún reintento del modelo con diferentes argumentos arreglaría.

Poniéndolo todo junto

Un solo archivo que combina las piezas de arriba — un tool de lectura, un tool de escritura, un resource y un prompt — tiene la forma realista de un primer servidor real, suficientemente pequeño para mantenerlo en la cabeza y suficientemente completo para ser útil detrás de un agente:

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.prompts import base
from pydantic import BaseModel

mcp = FastMCP("Support Tools")

class RefundOutcome(BaseModel):
    success: bool
    refund_id: str | None = None
    error: str | None = None

@mcp.tool()
def lookup_order(order_id: str) -> dict:
    """Look up an order by ID. Read-only, safe to call freely."""
    order = get_order(order_id)
    return order.model_dump() if order else {"error": "not found"}

@mcp.tool()
def issue_refund(order_id: str, amount: float, reason: str) -> RefundOutcome:
    """Issue a refund. Has a real side effect — use only after confirming with the user."""
    order = get_order(order_id)
    if order is None or amount > order.total:
        return RefundOutcome(success=False, error="Invalid order or amount")
    refund = process_refund(order_id, amount, reason)
    return RefundOutcome(success=True, refund_id=refund.id)

@mcp.resource("config://support-policies")
def support_policies() -> str:
    """Static policy text every support conversation should have as context."""
    return SUPPORT_POLICY_TEXT

@mcp.prompt()
def draft_refund_response(order_id: str, tone: str = "empathetic") -> list[base.Message]:
    """Draft a customer-facing message explaining a refund decision."""
    order = get_order(order_id)
    return [base.UserMessage(f"Write a {tone} message about the refund for order {order.id}.")]

if __name__ == "__main__":
    mcp.run(transport="stdio")

Observa la forma: un tool de lectura sin desventaja significativa en llamarlo, un tool de escritura que devuelve un resultado estructurado en lugar de lanzar, un resource para contexto estático siempre relevante, y un prompt para una acción iniciada por el usuario. Esa es una plantilla que vale la pena mantener a medida que agregas más — antes de agregar el tool número nueve, pregunta a cuál de estas cuatro categorías pertenece realmente.

Versionar tools sin romper clientes

Una vez que un servidor tiene clientes reales que dependen de él, cambiar el esquema de un tool es un cambio que rompe compatibilidad en el mismo sentido que cambiar la forma de respuesta de un endpoint REST — un cliente (o el modelo, en medio de una conversación) construido contra el esquema antiguo no sabe automáticamente sobre el nuevo. Algunas prácticas que hacen esto manejable:

  • Agregar parámetros opcionales con defaults, no cambiar los obligatorios existentes. Agregar un currency: str = "USD" opcional a issue_refund es seguro. Renombrar amount a refund_amount rompe cada sitio de llamada existente y cada modelo que ya vio la descripción antigua en su contexto.
  • Versionar el nombre del tool para cambios genuinamente incompatibles. Si el comportamiento de un tool necesita cambiar de una manera que no es retrocompatible, lanza issue_refund_v2 junto al original en lugar de mutar issue_refund debajo de los llamadores existentes, y depreca el antiguo en tu propio cronograma.
  • Mantener las descripciones sincronizadas con el comportamiento real. Un docstring desactualizado es peor que uno ausente — engaña activamente al modelo sobre lo que hace un tool o qué restricciones aplican, y ese modo de falla es silencioso hasta que alguien nota que el tool se está llamando incorrectamente.

La decisión de superficie de tools

Esta es la parte que no aparece en un tutorial de “hello world”: una vez que tu servidor tiene tools reales con efectos secundarios reales, qué eliges exponer es una decisión más grande que cómo implementas cualquier tool individual.

Primero lectura, luego escritura. Un tool lookup_order es de bajo riesgo — en el peor caso, un modelo confundido consulta el pedido equivocado. Un tool issue_refund no lo es — un modelo que malinterpreta el contexto y lo llama con el monto incorrecto ha causado un efecto real y externo. Expón los tools de lectura generosamente; expón los tools de escritura deliberadamente, y considera proteger todo lo irreversible detrás de un paso de confirmación o un patrón de human-in-the-loop en el agente que llama, en lugar de confiar solo en el juicio del modelo.

No expongas toda tu API interna solo porque puedes. Es tentador envolver cada función interna existente como un tool una vez que tienes el patrón del decorator dominado. Resiste eso. Cada tool que expones es algo que el modelo puede elegir llamar en un contexto que no anticipaste — cuantos más tools haya en un servidor, más se degrada la precisión de selección de tools del modelo, y mayor es tu superficie de ataque efectiva si el cliente (o el modelo) alguna vez es comprometido o manipulado por contenido inyectado. Un servidor con ocho tools bien descritos y específicos es más confiable y auditable que uno con cuarenta.

Piensa en quién realmente está llamando esto. Un servidor pensado para quedar detrás de un solo agente interno que tu equipo controla puede razonablemente exponer más que un servidor pensado para entregarse a un cliente de terceros que no controlas. Escribe, antes de agregar un tool, quién esperas que lo llame y qué se les permite hacer — esa respuesta debería informar tanto la superficie del tool como el modelo de auth de abajo.

Validar agresivamente dentro del tool, no solo a nivel de esquema. La validación de JSON Schema detecta “el modelo pasó un string donde se esperaba un int.” No detecta “el modelo pasó un ID de pedido válido que pertenece a un cliente diferente al de esta conversación.” Esa es una verificación de lógica de negocio que tu función tool tiene que hacer explícitamente — el protocolo no sabe de tu modelo de datos y no puede saberlo.

Autenticación antes de hacer el deploy

Un servidor corriendo sobre stdio en el laptop de alguien no tiene preocupaciones reales de auth — hereda los permisos que tenga el proceso local. En el momento en que sirvas sobre streamable-http y sea accesible desde cualquier lugar que no sea localhost, necesita la misma disciplina de autenticación que cualquier otra API.

La versión mínima viable es un bearer token verificado antes de que una petición llegue a tu lógica de tools:

from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware

VALID_TOKEN = os.environ["MCP_SERVER_TOKEN"]

class BearerAuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        auth = request.headers.get("authorization", "")
        if auth != f"Bearer {VALID_TOKEN}":
            return JSONResponse({"error": "unauthorized"}, status_code=401)
        return await call_next(request)

app = mcp.streamable_http_app()
app.add_middleware(BearerAuthMiddleware)

Esto pasa la barra de “no completamente abierto”, pero sigue siendo un único secreto compartido sin identidad por llamador y sin scoping — cualquiera con el token puede llamar a cualquier tool que expone el servidor. Para cualquier cosa más allá de un cliente interno de confianza, planea moverte hacia autorización basada en OAuth para que los tokens estén vinculados a una identidad específica y puedan limitarse a un subconjunto de tools en lugar de acceso todo-o-nada. Sea lo que sea que elijas, mantén la verificación de auth fuera de tus funciones tool — en la capa de transporte o middleware — para que un bug en un tool nunca pueda accidentalmente saltarse la verificación para otro.

Probar localmente con el MCP Inspector

Antes de conectar tu servidor a un agente real, el MCP Inspector te da una UI para llamar tools, leer resources y expandir prompts directamente, sin escribir un cliente.

mcp dev server.py

Esto lanza tu servidor y abre el Inspector en un navegador, donde puedes invocar issue_refund con argumentos de prueba y ver exactamente lo que vería el cliente — el esquema que recibe el modelo, el resultado estructurado, y cualquier ruta de error que actives al pasar entradas incorrectas. Vale la pena probar deliberadamente las rutas de error aquí: llama a issue_refund con un monto mayor que el total del pedido y confirma que el mensaje de error es algo que un modelo podría plausiblemente usar para autocorregirse, no un traceback crudo de Python.

Conclusión

La mecánica de un servidor MCP — decorators, type hints, un docstring — toma unos minutos aprenderla y genuinamente es la parte fácil. Las decisiones que determinan si el servidor es realmente bueno son las que un tutorial no puede darte: cuál de las tres primitivas es realmente una capacidad determinada, qué tan específico hacer cada tool, qué se mantiene interno, y qué autenticación necesita realmente un servidor accesible desde fuera de localhost antes de que alguien más se conecte. Entiende las primitivas correctamente y diseña la superficie de tools deliberadamente, y el resto — auth, testing, deploy — es ingeniería de backend estándar con una interfaz con forma de MCP.

Relacionado: Descubre cómo un servidor MCP facilita la investigación de software en Reverse Engineer Anything (REA): guía de uso.

Faizan Nadeem
Autor
Faizan Nadeem

AI/GenAI engineer specialising in agentic RAG, LangGraph, and production LLM systems with Python and FastAPI. 2 years building AI agents, RAG pipelines, and the backends behind them.

Más artículos