Módulo 8: Proyecto — el servidor MCP de Reservo completo

Las cuatro tools en el capstone

Descripción

La lección 02 confirmó que tools/list funciona dentro del servidor completo, con las cuatro tools canónicas de Reservo intactas. Esta lección va un paso más allá: ejecuta tools/call sobre las cuatro, una por una, en la misma corrida — list_roomsget_quotebook_roomcancel_booking — y cierra con el caso que M3 (lección 06) ya enseñó por separado: qué responde el servidor cuando el cliente pide una tool que no existe. Todo dentro del mismo proceso que también sirve resources y prompts, confirmando que ninguno de los otros dos primitivos interfiere con el comportamiento de tools/call.

Conexión con el módulo

Esta lección no agrega ningún método nuevo — call_tool ya es parte de MCPClient desde M6, lección 04, y handle_tools_call es exactamente el mismo código que ejecutaste en M3, lección 07. Lo único nuevo es el contexto: estas cuatro llamadas corren contra reservo_full_mcp_server.py, el servidor completo de la lección 02 de este módulo, no contra un servidor de solo-tools como en M3.


Ejemplo trabajado: las cuatro tools, en secuencia

from mcp_client import MCPClient
import json

client = MCPClient("reservo_full_mcp_server.py")
client.connect()

rooms = client.call_tool("list_rooms", {})
print(f"[client] list_rooms -> {rooms['content'][0]['text']}")

quote = client.call_tool("get_quote", {"room": "Focus", "tier": "pro", "hours": 3})
print(f"[client] get_quote Focus/pro/3h -> {quote['content'][0]['text']}")

booking = client.call_tool("book_room", {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"})
print(f"[client] book_room -> {booking['content'][0]['text']}")
booking_id = json.loads(booking["content"][0]["text"])["booking_id"]

cancel = client.call_tool("cancel_booking", {"id": booking_id})
print(f"[client] cancel_booking -> {cancel['content'][0]['text']}")

# tool desconocida: error de protocolo, no isError -- por eso se usa _send/_recv directo
client._send({"jsonrpc": "2.0", "id": next(client.request_ids), "method": "tools/call",
              "params": {"name": "delete_everything", "arguments": {}}})
unknown_response = client._recv()
print(f"[client] tools/call tool desconocida -> {unknown_response.get('error')}")

client.close()

Qué esperar (ejecutando python3.14 este_script.py):

[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "reservo-mcp-client", "version": "1.0.0"}}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}, "resources": {}, "prompts": {}}, "serverInfo": {"name": "reservo-mcp-server", "version": "1.0.0"}}}
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "method": "notifications/initialized"}
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "list_rooms", "arguments": {}}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 2, "result": {"content": [{"type": "text", "text": "[{\"room\": \"Focus\", \"rate_cents\": 2500}, {\"room\": \"Studio\", \"rate_cents\": 4000}, {\"room\": \"Boardroom\", \"rate_cents\": 8000}]"}], "isError": false}}
[client] list_rooms -> [{"room": "Focus", "rate_cents": 2500}, {"room": "Studio", "rate_cents": 4000}, {"room": "Boardroom", "rate_cents": 8000}]
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "get_quote", "arguments": {"room": "Focus", "tier": "pro", "hours": 3}}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 3, "result": {"content": [{"type": "text", "text": "{\"price_cents\": 6000}"}], "isError": false}}
[client] get_quote Focus/pro/3h -> {"price_cents": 6000}
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "book_room", "arguments": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 4, "result": {"content": [{"type": "text", "text": "{\"booking_id\": 1, \"confirmed\": true}"}], "isError": false}}
[client] book_room -> {"booking_id": 1, "confirmed": true}
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "cancel_booking", "arguments": {"id": 1}}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 5, "result": {"content": [{"type": "text", "text": "{\"cancelled\": true}"}], "isError": false}}
[client] cancel_booking -> {"cancelled": true}
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "delete_everything", "arguments": {}}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 6, "error": {"code": -32602, "message": "Unknown tool: delete_everything"}}
[client] tools/call tool desconocida -> {'code': -32602, 'message': 'Unknown tool: delete_everything'}
---- stderr de reservo_full_mcp_server.py ----
[server] arrancando, esperando mensajes por stdin...
[server] initialize <- cliente {'name': 'reservo-mcp-client', 'version': '1.0.0'}
[server] notifications/initialized <- el cliente confirma que ya puede operar
[server] tools/call <- list_rooms({})
[server] tools/call <- get_quote({'room': 'Focus', 'tier': 'pro', 'hours': 3})
[server] tools/call <- book_room({'room': 'Focus', 'tier': 'pro', 'hours': 3, 'member': 'Ana'})
[server] tools/call <- cancel_booking({'id': 1})
[server] tools/call <- tool desconocida: delete_everything

Seis mensajes con id (1 a 6), sin saltos: el handshake, y cinco tools/call en secuencia — cuatro exitosas, una que dispara el error de protocolo -32602. Nota que la quinta llamada no pasó por client.call_tool() — ese método hace self._recv()["result"], que lanzaría KeyError si la respuesta trae error en vez de result. Por eso el script usa client._send/client._recv directamente para este caso: es exactamente la situación que M6 (lección 02, "Errores comunes") ya advirtió sobre los métodos privados de MCPClient — normalmente no se usan desde fuera de la clase, salvo que estés depurando un caso que el método público no contempla.

El ancla de la guía queda confirmada, otra vez, en su quinta aparición: get_quote Focus/pro/3h sigue dando 6000 centavos, ahora dentro del servidor completo con tools, resources y prompts activos a la vez — nada en la presencia de los otros dos primitivos cambió el resultado de esta tool.


Errores comunes

  1. Usar client.call_tool() para el caso de la tool desconocida y que el script explote con KeyError: 'result'. call_tool asume que la respuesta trae result — una respuesta con error (como la de una tool desconocida) rompe esa suposición. Cuando quieras probar un caso de error a propósito, usa client._send/client._recv para inspeccionar la respuesta cruda antes de decidir qué campo leer.

  2. Perder el booking_id real entre book_room y cancel_booking. El script de esta lección lo captura explícitamente con json.loads(booking["content"][0]["text"])["booking_id"] antes de usarlo — inventar el id (por ejemplo, asumir que siempre es 1 sin leerlo) funciona por casualidad en una corrida limpia, pero deja de funcionar en cuanto agregas una reserva más antes de esta secuencia.

  3. Confundir el id: 6 del sexto mensaje JSON-RPC con algo relacionado al error. Es solo el contador de mensajes de la conexión (itertools.count(1) de MCPClient) avanzando uno más — no tiene ninguna relación con el código de error -32602 ni con ningún dato de negocio.


Ejercicios

Ejercicio 1: Cuenta las tools exitosas contra las que fallan (Fácil)

De la transcripción de esta lección, ¿cuántas llamadas a tools/call terminaron con result/isError: false, cuántas con result/isError: true, y cuántas con un error de protocolo? Da el nombre de la tool en cada caso.

Ver solución
  • result/isError: false (4): list_rooms, get_quote, book_room, cancel_booking — las cuatro tools canónicas, cada una ejecutada con éxito.
  • result/isError: true (0): ninguna en esta transcripción — no se provocó ninguna excepción de Python dentro de una tool válida.
  • error de protocolo (1): delete_everything, una tool que no existe en MCP_TOOLSfind_tool devuelve None, y handle_tools_call responde con error.code: -32602 antes de intentar ejecutar nada.

Ejercicio 2: Provoca el error de ejecución, no el de protocolo (Medio)

Llama get_quote con un room que no existe en ROOM_RATE_CENTS (por ejemplo, "Ballroom") — a diferencia de una tool desconocida, esta vez el nombre de la tool SÍ existe, así que pasa la validación de find_tool, pero ROOM_RATE_CENTS["Ballroom"] lanza KeyError dentro de la función de negocio. Confirma que el resultado es isError: true (dentro de un result), no un error de protocolo.

Ver solución
from mcp_client import MCPClient

client = MCPClient("reservo_full_mcp_server.py", verbose=False)
client.connect()

response = client.call_tool("get_quote", {"room": "Ballroom", "tier": "pro", "hours": 3})
print(response)

Salida real:

{'content': [{'type': 'text', 'text': "Error executing tool 'get_quote': 'Ballroom'"}], 'isError': True}

Explicación: esta vez client.call_tool() SÍ funciona sin problema, porque la respuesta trae result (con isError: true dentro), no error a nivel de protocolo — es la misma distinción que M3, lección 06, estableció con cuidado: una tool que existe pero falla al ejecutarse es un error de ejecución (isError: true, dentro de un result válido), mientras que una tool que no existe en absoluto es un error de protocolo (error, sin result). El mensaje de Python ('Ballroom', solo la clave entre comillas) viene de convertir el KeyError a string con str(exc) dentro de handle_tools_call — menos descriptivo que un mensaje a medida, pero suficiente para saber qué salió mal.

Ejercicio 3: Reserva dos salas distintas y confirma booking_id consecutivos (Difícil)

En la misma corrida (mismo client, sin reconectar), llama book_room dos veces con salas distintas ("Studio" y "Boardroom", mismo member), y confirma con assert que los booking_id son 1 y 2 respectivamente — sin cancelar ninguna entre medio. Después, cancela solo la segunda y confirma que BOOKINGS (del lado del servidor, indirectamente, a través de un tercer get_quote que no debería verse afectado) sigue funcionando con normalidad para la sala que no se tocó.

Ver solución
from mcp_client import MCPClient
import json

client = MCPClient("reservo_full_mcp_server.py", verbose=False)
client.connect()

booking_studio = client.call_tool("book_room", {"room": "Studio", "tier": "basic", "hours": 2, "member": "Marta"})
id_studio = json.loads(booking_studio["content"][0]["text"])["booking_id"]

booking_boardroom = client.call_tool("book_room", {"room": "Boardroom", "tier": "pro", "hours": 1, "member": "Marta"})
id_boardroom = json.loads(booking_boardroom["content"][0]["text"])["booking_id"]

assert id_studio == 1
assert id_boardroom == 2
print(f"booking_id Studio: {id_studio}, booking_id Boardroom: {id_boardroom}")

cancel_boardroom = client.call_tool("cancel_booking", {"id": id_boardroom})
assert json.loads(cancel_boardroom["content"][0]["text"])["cancelled"] is True

# get_quote sobre Studio sigue funcionando con normalidad -- no depende de BOOKINGS
quote_studio = client.call_tool("get_quote", {"room": "Studio", "tier": "basic", "hours": 2})
print(f"get_quote Studio/basic/2h sigue dando -> {quote_studio['content'][0]['text']}")

client.close()

Salida esperada:

booking_id Studio: 1, booking_id Boardroom: 2
get_quote Studio/basic/2h sigue dando -> {"price_cents": 8000}

Explicación: _booking_ids = itertools.count(1) es un único contador por proceso servidor —no por sala, no por tool— así que cada book_room exitoso avanza el mismo contador sin importar qué sala se reserve: la primera reserva de la corrida (Studio) recibe 1, la segunda (Boardroom) recibe 2. Cancelar la reserva de Boardroom (BOOKINGS.pop) no afecta en nada a get_quote, que nunca lee BOOKINGS — solo calcula un precio a partir de ROOM_RATE_CENTS, sin ninguna dependencia del estado de reservas existentes. Esto confirma, con un caso concreto, la separación de responsabilidades dentro de las cuatro tools: get_quote es de solo lectura de tarifas, book_room/cancel_booking son las únicas que tocan BOOKINGS.


Resumen y siguiente paso

  • Las cuatro tools canónicas de Reservo se ejecutaron en secuencia dentro del servidor completo, con el mismo comportamiento exacto que ya conocías de M3 — ninguna interferencia de resources ni prompts coexistiendo en el mismo proceso.
  • El error de protocolo de una tool desconocida (-32602) y el error de ejecución de un argumento inválido (isError: true) siguen siendo la misma distinción de M3, lección 06 — verificada de nuevo en el capstone.
  • El ancla de la guía, get_quote Focus/pro/3h = 6000 centavos, quedó confirmada por quinta vez.

Siguiente lección: 04 — Los dos resources en el capstone. resources/read sobre las dos políticas ancla, dentro de este mismo servidor completo, incluido el error -32002 de una URI que no existe.


Recursos adicionales

  1. Model Context Protocol — Specification 2025-06-18: Toolstools/call, ejecutado en esta lección sobre las cuatro tools canónicas.
  2. Model Context Protocol — Specification 2025-06-18: Base Protocol — Los códigos de error JSON-RPC estándar (-32601, -32602) que este servidor usa sin modificar.
  3. Python — ExcepcionesKeyError y try/except, la base del Ejercicio 2.
  4. Python — subprocess — La base de toda la comunicación cliente↔servidor de esta lección.