Módulo 3: Tools sobre MCP

`tools/list`: anunciar qué ofrece el servidor

Descripción

La lección anterior confirmó que declarar la capability tools no alcanza — hace falta implementar el método que la cumple. Esta lección implementa exactamente ese método: tools/list, el primero de los dos que completan la capability tools. Vas a declarar las cuatro tools canónicas de Reservo como objetos MCP Tool, agregarlas al dispatch del servidor, y ejecutar el request completo contra un cliente real — leyendo, campo por campo, la respuesta que trae la "carta" entera.

Conexión con el módulo

Esta lección construye la primera pieza real del servidor reservo_tools_mcp_server.py que vas a usar de aquí en adelante en todo el módulo: las cuatro Tool declaradas y el handler de tools/list. La lección 05 agrega la segunda pieza (tools/call) sobre esta misma base — nada de lo que escribas aquí se descarta.


Analogía: la carta completa, servida de una vez

Siguiendo la analogía del módulo: tools/list es el momento en que el mesero le entrega al comensal la carta entera, de una sola vez — no un plato a la vez, no una descripción verbal improvisada, sino un documento completo con cada opción, su nombre y qué hace falta pedir para prepararla. El comensal no tiene que preguntar "¿qué platos tienen?" y esperar una respuesta parcial; recibe la lista completa en un solo intercambio, y a partir de ahí decide.

Esa es exactamente la forma de tools/list: un request sin parámetros relevantes, y una response con todas las tools del servidor en un solo arreglo. No hay paginación en el caso de Reservo (la especificación sí contempla un cursor opcional para servidores con catálogos grandes, pero con cuatro tools no hace falta) — la carta completa llega de una vez.


La forma exacta del mensaje

Un request tools/list es, en JSON-RPC, de los más simples que vas a ver en esta guía — no necesita casi ningún parámetro:

{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}

Y la response trae un result con una única clave, tools, cuyo valor es un arreglo de objetos Tool. Cada Tool tiene esta forma:

{
  "name": "get_quote",
  "description": "Cotiza el precio de una sala...",
  "inputSchema": {
    "type": "object",
    "properties": { "...": "..." },
    "required": ["room", "tier", "hours"]
  }
}

Tres campos, y los tres deberían resultarte familiares si ya trabajaste el contrato de tool en agent-fundamentals: name (el identificador exacto que después vas a usar en tools/call), description (el texto que le dice al modelo, dentro del host, cuándo usar esta tool — la misma disciplina de "usa esta tool cuando..." que ya conocías), e inputSchema (el JSON Schema completo de los argumentos — la lección 04 lo compara byte a byte con el input_schema de la Messages API).


Declarando las cuatro tools canónicas como objetos MCP

Aquí están los cuatro Tool de Reservo, con la misma description que ya usaste en agent-fundamentals, ahora con la clave inputSchema (no input_schema) que exige MCP:

MCP_TOOLS = [
    {
        "name": "list_rooms",
        "description": (
            "Lista todas las salas de Reservo con su tarifa base por hora en "
            "centavos. Usa esta tool cuando el usuario pregunta que salas hay "
            "disponibles, sin haber elegido todavia una sala especifica."
        ),
        "inputSchema": {"type": "object", "properties": {}},
    },
    {
        "name": "get_quote",
        "description": (
            "Cotiza el precio de una sala para un tier y una cantidad de horas, "
            "sin reservar nada. Usa esta tool cuando el usuario pregunta cuanto "
            "cuesta una reserva."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "room": {"type": "string", "enum": ["Focus", "Studio", "Boardroom"]},
                "tier": {"type": "string", "enum": ["basic", "pro"]},
                "hours": {"type": "integer"},
            },
            "required": ["room", "tier", "hours"],
        },
    },
    {
        "name": "book_room",
        "description": (
            "Crea una reserva CONFIRMADA para una sala, un tier y una cantidad "
            "de horas, a nombre de un miembro. Tiene efectos reales: genera una "
            "reserva de verdad. Usa esta tool solo cuando el usuario pide "
            "reservar explicitamente, no cuando solo pregunta el precio."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "room": {"type": "string", "enum": ["Focus", "Studio", "Boardroom"]},
                "tier": {"type": "string", "enum": ["basic", "pro"]},
                "hours": {"type": "integer"},
                "member": {"type": "string"},
            },
            "required": ["room", "tier", "hours", "member"],
        },
    },
    {
        "name": "cancel_booking",
        "description": (
            "Cancela una reserva existente a partir de su id. Es una accion "
            "destructiva e irreversible. Usa esta tool cuando el usuario pide "
            "cancelar o anular una reserva que ya hizo."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {"id": {"type": "integer"}},
            "required": ["id"],
        },
    },
]

Compara esta lista con RESERVO_TOOLS de agent-fundamentals (Módulo 2, lección 07): mismo name, misma description, y el mismo objeto de schema dentro — la única diferencia sintáctica es la clave que lo envuelve, inputSchema en vez de input_schema. No es casualidad: la lección 04 dedica todo su espacio a confirmar que esta similitud no es superficial.

El handler que responde a tools/list es, con la lista ya armada, casi trivial:

def handle_tools_list(msg_id, params: dict) -> dict:
    log(f"[server] tools/list <- devolviendo {len(MCP_TOOLS)} tools")
    return {"jsonrpc": "2.0", "id": msg_id, "result": {"tools": MCP_TOOLS}}

Y se conecta al dispatch de mensajes del servidor —el mismo for raw_line in sys.stdin que ya conoces de M2— agregando una rama más al if/elif:

elif method == "tools/list":
    send(handle_tools_list(msg_id, params))

Ejemplo trabajado: tools/list ejecutado, con la respuesta completa

Con el handler conectado, corremos el handshake (M2) seguido de un único tools/list, y pedimos la respuesta formateada para leerla con comodidad:

# tools_list_only_client.py
import subprocess, sys, json, itertools

request_ids = itertools.count(1)
proc = subprocess.Popen(
    [sys.executable, "reservo_tools_mcp_server.py"],
    stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
    text=True, bufsize=1,
)

init = {"jsonrpc": "2.0", "id": next(request_ids), "method": "initialize",
        "params": {"protocolVersion": "2025-06-18", "capabilities": {},
                   "clientInfo": {"name": "reservo-mcp-client", "version": "1.0.0"}}}
proc.stdin.write(json.dumps(init) + "\n"); proc.stdin.flush()
proc.stdout.readline()
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
proc.stdin.flush()

request = {"jsonrpc": "2.0", "id": next(request_ids), "method": "tools/list", "params": {}}
print(f"[client -> server] {json.dumps(request)}")
proc.stdin.write(json.dumps(request) + "\n"); proc.stdin.flush()
response = json.loads(proc.stdout.readline())

print()
print("[server -> client] (formateado para lectura):")
print(json.dumps(response, indent=2, ensure_ascii=False))

print()
for tool in response["result"]["tools"]:
    required = tool["inputSchema"].get("required", [])
    props = list(tool["inputSchema"].get("properties", {}).keys())
    print(f"  {tool['name']:15} properties={props}  required={required}")

proc.stdin.close()
proc.wait(timeout=5)

Qué esperar (la respuesta cruda, en una sola línea de stdout, es la que de verdad viaja por el protocolo — aquí se imprime formateada solo para que la puedas leer):

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "list_rooms",
        "description": "Lista todas las salas de Reservo con su tarifa base por hora en centavos. Usa esta tool cuando el usuario pregunta que salas hay disponibles, sin haber elegido todavia una sala especifica.",
        "inputSchema": { "type": "object", "properties": {} }
      },
      {
        "name": "get_quote",
        "description": "Cotiza el precio de una sala para un tier y una cantidad de horas, sin reservar nada. Usa esta tool cuando el usuario pregunta cuanto cuesta una reserva.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "room": {"type": "string", "enum": ["Focus", "Studio", "Boardroom"]},
            "tier": {"type": "string", "enum": ["basic", "pro"]},
            "hours": {"type": "integer"}
          },
          "required": ["room", "tier", "hours"]
        }
      },
      {
        "name": "book_room",
        "description": "Crea una reserva CONFIRMADA para una sala, un tier y una cantidad de horas, a nombre de un miembro. Tiene efectos reales: genera una reserva de verdad. Usa esta tool solo cuando el usuario pide reservar explicitamente, no cuando solo pregunta el precio.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "room": {"type": "string", "enum": ["Focus", "Studio", "Boardroom"]},
            "tier": {"type": "string", "enum": ["basic", "pro"]},
            "hours": {"type": "integer"},
            "member": {"type": "string"}
          },
          "required": ["room", "tier", "hours", "member"]
        }
      },
      {
        "name": "cancel_booking",
        "description": "Cancela una reserva existente a partir de su id. Es una accion destructiva e irreversible. Usa esta tool cuando el usuario pide cancelar o anular una reserva que ya hizo.",
        "inputSchema": {
          "type": "object",
          "properties": { "id": {"type": "integer"} },
          "required": ["id"]
        }
      }
    ]
  }
}
  list_rooms      properties=[]  required=[]
  get_quote       properties=['room', 'tier', 'hours']  required=['room', 'tier', 'hours']
  book_room       properties=['room', 'tier', 'hours', 'member']  required=['room', 'tier', 'hours', 'member']
  cancel_booking  properties=['id']  required=['id']

Cuatro tools, en el mismo orden en que las declaraste en MCP_TOOLS, cada una con su inputSchema completo. Fíjate en list_rooms: properties: {} y sin required — la única de las cuatro que no necesita ningún argumento, coherente con lo que ya sabías desde agent-fundamentals.


Errores comunes

  1. Escribir input_schema en vez de inputSchema por costumbre de la Messages API. Es el error de transcripción más probable si vienes de agent-fundamentals con esa clave grabada en los dedos. MCP usa inputSchema (camelCase, sin guion bajo) en toda su especificación — un detalle puramente sintáctico, pero que rompe el parseo del cliente si te equivocas.

  2. Olvidar params: {} en el request, aunque esté vacío. JSON-RPC no exige params cuando el método no necesita argumentos, pero MCP e implementaciones reales suelen incluirlo igual por convención de forma. El servidor de esta lección lo acepta con o sin ese campo (message.get("params", {}) cubre ambos casos), pero es buena práctica incluirlo explícitamente.

  3. Pensar que tools/list ejecuta algo. No — es puramente informativo. Llamarlo cien veces seguidas no reserva ninguna sala ni cambia ningún estado del servidor; solo devuelve, cada vez, la misma lista de contratos.


Ejercicios

Ejercicio 1: Lee la carta (Fácil)

Mirando la respuesta completa de tools/list de esta lección, sin ejecutar nada: ¿cuál de las cuatro tools tiene el inputSchema con más campos en properties? ¿Cuántos campos son required en esa misma tool?

Ver solución

book_room, con cuatro campos en properties (room, tier, hours, member) — y los cuatro son required. Es la única de las cuatro tools que necesita saber a nombre de quién queda la reserva, además de los tres datos que ya pedía get_quote.

Ejercicio 2: Cuenta bytes de la carta completa (Medio)

Usando la respuesta de tools/list del ejemplo trabajado, calcula cuántos bytes ocupa el JSON completo de la respuesta (sin el formateo de lectura, la línea cruda tal como viaja por stdout) con json.dumps. Compara ese tamaño contra la suma de los cuatro contratos declarados por separado en agent-fundamentals (lección 07 de ese módulo: 283 + 438 + 578 + 314 bytes) y explica por qué deberían ser prácticamente iguales.

Ver solución
import json

# MCP_TOOLS es la misma lista de la leccion, con inputSchema en vez de input_schema
mcp_list_response = {"tools": MCP_TOOLS}
mcp_bytes = len(json.dumps(mcp_list_response, ensure_ascii=False))
agent_fundamentals_bytes = 283 + 438 + 578 + 314

print("bytes de result.tools (MCP):", mcp_bytes)
print("suma de los 4 contratos (agent-fundamentals):", agent_fundamentals_bytes)

Salida esperada (el valor exacto puede variar en un puñado de bytes según el separador que use tu json.dumps, pero el orden de magnitud coincide):

bytes de result.tools (MCP): 1652
suma de los 4 contratos (agent-fundamentals): 1613

Explicación: los dos números son cercanos porque el contenido es, en esencia, el mismo — cuatro objetos con name, description e input schema, con la única diferencia real siendo la clave inputSchema (10 caracteres) contra input_schema (12 caracteres, con guion bajo) repetida cuatro veces, más el envoltorio extra {"tools": [...]} de MCP. La diferencia de un puñado de bytes confirma, de otra forma, lo que la lección 04 va a mostrar con más rigor: es el mismo contenido, empaquetado con una convención de nombres ligeramente distinta.

Ejercicio 3: Un servidor con catálogo variable (Difícil)

Modifica MCP_TOOLS (en tu propia copia del servidor) para que list_rooms tenga una segunda versión con un argumento opcional city ({"type": "string"}, no incluido en required), sin tocar las otras tres tools. Ejecuta tools/list contra tu versión modificada y confirma que la única tool que cambió es list_rooms, comparando el inputSchema de las otras tres contra el original byte a byte.

Ver solución
# En tu copia de reservo_tools_mcp_server.py, reemplaza solo la entrada de list_rooms:
MCP_TOOLS[0] = {
    "name": "list_rooms",
    "description": (
        "Lista todas las salas de Reservo con su tarifa base por hora en "
        "centavos, opcionalmente filtradas por ciudad. Usa esta tool cuando "
        "el usuario pregunta que salas hay disponibles."
    ),
    "inputSchema": {
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": [],
    },
}
import json
import reservo_tools_mcp_server as original  # la version SIN modificar, para comparar

# ... ejecutar tools/list contra tu version modificada como en el ejemplo trabajado ...
modified_tools = {t["name"]: t for t in response["result"]["tools"]}
original_tools = {t["name"]: t for t in original.MCP_TOOLS}

for name in ("get_quote", "book_room", "cancel_booking"):
    unchanged = json.dumps(modified_tools[name], sort_keys=True) == json.dumps(original_tools[name], sort_keys=True)
    print(f"{name:15} sin cambios: {unchanged}")

print("list_rooms cambio:", modified_tools["list_rooms"] != original_tools["list_rooms"])

Salida esperada:

get_quote       sin cambios: True
book_room       sin cambios: True
cancel_booking  sin cambios: True
list_rooms cambio: True

Explicación: tools/list devuelve exactamente lo que hay en MCP_TOOLS en el momento de la llamada — modificar una entrada de la lista no tiene ningún efecto sobre las otras tres, porque cada Tool es un objeto independiente dentro del arreglo. Este ejercicio anticipa un patrón real: un servidor MCP en producción puede evolucionar su catálogo de tools con el tiempo (agregar un argumento opcional, agregar una tool nueva) sin que eso rompa el contrato de las tools que no cambiaron — siempre que el cambio sea aditivo (un argumento opcional nuevo, no uno requerido nuevo que rompería a los clientes existentes que no lo mandan).


Resumen y siguiente paso

  • tools/list es un request simple (params: {}) que devuelve result.tools, un arreglo completo de objetos Tool — sin paginación necesaria para un catálogo tan chico como el de Reservo.
  • Cada Tool tiene tres campos: name, description, e inputSchema (JSON Schema completo) — declaramos las cuatro tools canónicas de Reservo con esta forma exacta.
  • Ejecutamos el request completo y confirmamos, campo por campo, que las cuatro tools llegan con su inputSchema intacto — list_rooms sin argumentos, las otras tres con sus campos y required correctos.
  • tools/list es puramente informativo: no ejecuta nada, no cambia ningún estado del servidor.

Siguiente lección: 04 — El inputSchema: mismo JSON Schema, protocolo nuevo. Confirmamos, comparando objetos byte a byte, que el inputSchema que acabas de ver es exactamente el mismo JSON Schema que ya conocías como input_schema de la Messages API de Claude.


Recursos adicionales

  1. Model Context Protocol — Specification 2025-06-18: Tools — Listing Tools — La forma exacta de tools/list, el objeto Tool y sus campos.
  2. Anthropic — Tool use (function calling) overview — El contrato de tool que ya conocías, para comparar contra el Tool de MCP.
  3. JSON Schema — La especificación completa del formato que describe cada inputSchema.
  4. Python — jsonjson.dumps con indent=2, usado en esta lección para formatear la respuesta cruda de forma legible.