MCP · ·10 min ·Intermediate

MCPs 02

Cómo programar un servidor MCP desde cero en Python: tools, resources y prompts, buenas prácticas de diseño, y cómo añadir autenticación OAuth 2.1 cuando el servidor maneja datos de usuarios.

Introducción

En la primera parte de este tutorial sobre los MCPs vimos qué eran y cómo incorporar uno a un agente. En este, vamos con cómo se programan. Puede hacerse con Node, con Python y con una larga lista de lenguajes más, pero recomiendo Python encarecidamente. La IA habla en Python.

Un servidor MCP en Python necesita hacer tres cosas:

  • Declarar qué tools expone, con list_tools, que devuelve el nombre, descripción y parámetros de cada tool
  • Ejecutar las tools, con el decorador call_tool, que recibe el nombre y los argumentos y devuelve el resultado
  • Arrancar el servidor, para que el cliente pueda comunicarse con él

Instalación

Creamos un entorno virtual:

python -m venv .venv

Lo activamos:

# bash/zsh
source .venv/Scripts/activate

# PowerShell
.venv\Scripts\Activate.ps1

Instalamos el SDK oficial:

pip install MCP

El servidor

La estructura mínima es siempre la misma:

server.py
instancia del servidor
- list_tools: qué ofrezco
- call_tool: qué hago cuando me llaman
- main: arranco y escucho

Lo más importante antes de ponerte a escribir código: las descripciones de las tools son lo más crítico. El agente no tiene lógica especial para saber cuándo usar tu tool, simplemente lee la descripción y decide. Una descripción mala o vaga supondrá que el agente nunca la usará.

El archivo puede llamarse de cualquier manera. Lo habitual es server.py si el proyecto solo tiene un servidor, o algo más descriptivo como MCP_server.py si convive con más código.

Aquí va el equivalente a un hola mundo, un MCP, un tanto miserable en cuanto a capacidades, que simplemente salude:

# server.py
from MCP.server.fastMCP import FastMCP

# Instancia del servidor
MCP = FastMCP("hello-world")

# Definimos la tool con su descripción
@MCP.tool()
def greet(name: str) -> str:
    """Greets a person by name. Use it when the user wants a personalized greeting."""
    return f"Hello, {name}!"

# Arrancamos el servidor HTTP en localhost:8000/MCP
if __name__ == "__main__":
    MCP.run(transport="streamable-http")

Lo arrancamos:

python server.py

Y lo instalamos en Claude Code:

claude MCP add --transport http hello-world http://localhost:8000/MCP

El hello-world del comando es el nombre que le das al servidor en Claude Code — el alias con el que lo identificará internamente, el que verás al hacer claude MCP list o /MCP. No tiene que coincidir con el nombre definido en el código, aunque lo lógico es que sea el mismo.

Si ejecutamos claude MCP list debería salirnos algo así:

hello-world │ http://localhost:8000/MCP │ ✓ Conectado

Y ya podemos interactuar con las herramientas que nos ofrece el MCP. Si escribimos en Claude Code:

Greet Aristófanes

Nos debería salir algo así:

 Greet Aristófanes

  Called hello-world (ctrl+o to expand)

 Hello, Aristófanes!

Tools, prompts y resources

En el ejemplo anterior solo hemos usado tools, pero hay tres primitivas en MCP:

  1. Tools: las que hemos visto, son funciones que el agente ejecuta. Tienen side effects: escriben, llaman APIs, modifican datos. El agente las invoca cuando decide que las necesita.

  2. Resources: son datos que el agente lee como contexto. Son de solo lectura, los puede leer, pero no ejecutar y no tienen side effects. El usuario o la aplicación decide cuándo cargarlos.

  3. Prompts: son plantillas de instrucciones reutilizables que el servidor pone a disposición del cliente. Por ejemplo, un servidor de análisis de datos podría exponer un prompt llamado analizar-csv que ya sabe cómo estructurar la petición para analizar un archivo. El usuario los invoca explícitamente.

La distinción de control es importante:

  • Tools: las controla el modelo (decide cuándo usarlas).
  • Resources: las controla la aplicación (se cargan cuando el cliente lo decide).
  • Prompts: los controla el usuario (los invoca explícitamente).

Un resource, por ejemplo en un servidor MCP de documentación puede ser algo así:

@MCP.resource("docs://guia-instalacion")
def get_install_guide() -> str:
    """Installation guide for the project."""
    return open("docs/install.md").read()

El agente puede referenciar ese resource con @servidor:docs://guia-instalacion en su prompt y leerá el contenido directamente, sin invocar ninguna tool.

En síntesis: una tool hace algo (ejecuta código, llama a una API, escribe en una base de datos). Un resource solo expone datos para que el agente los lea como contexto.

Los prompts son eso, colecciones de prompts. Por ejemplo:

@MCP.prompt()
def analyze_csv(filename: str) -> str:
    """Template for analyzing a CSV file."""
    return f"""
    Analyze the file {filename} and provide:
    1. A summary of the columns and their data types
    2. The first 5 rows as an example
    3. Any data quality issues you detect (nulls, duplicates, outliers)
    4. Three insights from the data
    """

El usuario lo invocaría en Claude Code con:

/MCP__hello-world__analyze_csv data.csv

Y Claude recibiría ese prompt ya formateado como punto de partida, sin que el usuario tenga que escribir las instrucciones desde cero cada vez. La diferencia con un skill es sutil: un skill son instrucciones que viven en un archivo Markdown en tu proyecto. Un Prompt MCP vive en el servidor y está disponible para cualquier cliente que se conecte a él, independientemente del proyecto.

Buenas prácticas

Algunos consejos:

1. Un servidor, un propósito Cada servidor MCP debe tener una función clara y acotada. No intentes hacer un servidor que haga de todo.

2. Las descripciones son tu UI MCP es una interfaz de usuario para agentes, no para humanos. Las docstrings son instrucciones. Especifica cuándo usar la tool, cómo formatear los argumentos y qué esperar de vuelta.

3. Diseña tools de alto nivel, no wrappers de API No conviertas endpoints REST 1:1 en tools MCP. Diseña las tools alrededor de lo que el agente quiere conseguir, no alrededor de cómo está construida un API.

4. Hazlas idempotentes El servidor será llamado por agentes que pueden reintentar o paralelizar peticiones. Haz las llamadas idempotentes y devuelve resultados deterministas para los mismos inputs.

5. Los mensajes de error también son contexto Los errores los lee el agente, no un humano. Hazlos accionables y con información suficiente para que el agente pueda corregir su llamada.

Autenticación

Como vimos, en muchos escenarios es probable que un skill sea mejor que un MCP, pero hay un caso donde el formato MCP sigue teniendo particular relevancia, al menos en el momento, y es cuando hay que autenticarse.

Como veremos, un skill es un archivo Markdown con instrucciones. No tiene forma nativa de gestionar autenticación. Si necesita acceder a Jira o GitHub en nombre del usuario, alguien tiene que poner una API key en algún sitio, normalmente como variable de entorno global. Eso no escala en entornos enterprise y es un problema de seguridad evidente.

Un servidor MCP con OAuth 2.1, en cambio, sí va securizado y además con un estándar. Cada usuario se autentica con sus propias credenciales, el token tiene scopes limitados, se renueva automáticamente, y hay un registro de quién hizo qué.

En cualquier caso, recordemos que la autorización en MCP es opcional, pero necesaria en cuanto tu servidor maneje datos de usuarios, acciones administrativas, o cualquier cosa que requiera saber quién está haciendo qué.

Es un tema muy complejo, pero apunto unas pinceladas que espero nos permitan hacernos una idea sobre cómo funciona el mecanismo.

El flujo OAuth 2.1 paso a paso

Cuando un cliente intenta conectarse a un servidor MCP protegido, ocurre esto:

1. Handshake inicial

El servidor responde con un 401 Unauthorized e indica al cliente dónde encontrar la información de autorización:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="MCP",
  resource_metadata="https://tu-servidor.com/.well-known/oauth-protected-resource"

(Eso del “well-known” es una convención estándar de las APIs web para exponer metadatos de forma predecible. El .well-known/ es una ruta reservada -definida en el RFC 8615- que los clientes saben que pueden consultar sin que nadie se lo diga explícitamente).

2. Descubrimiento de metadatos

El cliente recupera ese documento para saber qué servidor de autorización usar y qué scopes están disponibles (tools, resources, prompts). Algo así:

{
  "resource": "https://tu-servidor.com/MCP", // <-- el recurso
  "authorization_servers": ["https://auth.tu-servidor.com"], // <-- dónde se puede autenticar
  "scopes_supported": ["MCP:tools", "MCP:resources"] // <-- a qué tendrá acceso
}

3. Registro y Autenticación del cliente

Lo que sigue es algo complicado, si no estamos familiarizados con OAuth y demás, quedémonos solo con la idea.

El siguiente paso es que el cliente se autentique para que el MCP le deje acceder a los recursos y para eso hay 3 actores en juego:

a. El agente que está atacando al MCP b. El MCP que le está indicando al agente que necesita una llave, un token, para solicitar los datos c. Un servidor de Autenticación que comprobará que el agente es un cliente válido.

El objetivo, en síntesis, es que el cliente se autentique para obtener un token que se incluirá en cada llamada al servidor.

Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

El cliente se puede autenticar de dos formas principales:

a) Una es mediante un registro estático o, mejor dicho, controlado. Nos registraríamos en algún lado, como un portal, y nos darían un client_id y un client_secret que usaríamos contra el servidor de Autenticación para que el agente obtenga el token de acceso.

Por ejemplo, una vez obtenidos un client_id y un client_secret, podríamos ejecutar esto una vez:

claude MCP add --transport http mi-servidor \
  --client-id tu-client-id \
  --client-secret \
  --callback-port 8080 \
  https://MCP.ejemplo.com/MCP

El —client-secret sin valor hace que Claude Code te lo pida de forma enmascarada en la terminal y así no queda en el historial. Luego dentro de Claude Code:

/MCP > selecciona el servidor > Authenticate

Se abre el navegador, haces login con tu cuenta, y Claude Code obtiene el token de acceso.

b) La otra es lo que se conoce como Dynamic Client Registration (DCR), que permite que el cliente se registre solo en tiempo de ejecución, sin casi intervención humana. Es decir, El cliente se registra solo, automáticamente, la primera vez que intenta conectarse. No hace falta ningún portal ni ningún administrador. Por ejemplo, en el caso de Figma que vimos en la entrada anterior

  • El usuario ejecuta
claude MCP add --transport http figma https://MCP.figma.com/MCP
  • El agente pregunta si queremos autenticarnos (/MCP > Authenticate)
  • Claude Code se registró solo con Figma via DCR (fase 1 automática)
  • Se abre el navegador, nos logamos con cuenta de Figma (fase 2)
  • A partir de ahí Claude Code tiene un token que identifica la cuenta y lo usa en cada llamada

4.4. Proteger el servidor

Por último, el servidor MCP tiene que validar que las peticiones que recibe van autenticadas. En un servidor simple con token fijo, bastaría con un middleware como este:

# server.py
from MCP.server.fastMCP import FastMCP
from starlette.requests import Request
from starlette.responses import Response

MCP = FastMCP("hello-world")

# Middleware de autenticación
async def auth_middleware(request: Request, call_next):
    token = request.headers.get("Authorization", "")
    # Validaríamos el JWT
    # Validaríamos los scopes
    # Etcétera
    if token != "Bearer mi-token-secreto":
        return Response("Unauthorized", status_code=401)
    return await call_next(request)

MCP.add_middleware(auth_middleware)

@MCP.tool()
def greet(name: str) -> str:
    """Greets a person by name."""
    return f"Hello, {name}!"

if __name__ == "__main__":
    MCP.run(transport="streamable-http")

Bueno, es más complejo todo, claro está, pero con lo dicho espero que basta para hacernos una idea. Si quieres profundizar, puedes consultar:

https://modelcontextprotocol.io/docs/tutorials/security/authorization