Módulo 5: Prompts — plantillas reutilizables

El prompt `plan_booking` para Reservo

Descripción

Las lecciones 03, 04 y 05 te dieron plan_booking en piezas: primero como una entrada de catálogo, después activado con y sin su argumento, después con su PromptMessage desarmado campo por campo. Esta lección junta las tres piezas en el servidor completo, ejecutado de punta a punta en una sola corrida, y se detiene en el detalle que las lecciones anteriores usaron pero no explicaron del todo: cómo se construye el texto a partir de los argumentos — la lógica de sustitución que convierte "un argumento opcional llamado room" en una instrucción en prosa, coherente, tenga o no tenga sala especificada.

Conexión con el módulo

Esta es la lección de síntesis del módulo, en el mismo lugar donde el Módulo 2 tuvo su "handshake completo, ejecutado" (lección 07) y el Módulo 3 tuvo su tools/call sobre las cuatro tools canónicas (lección 05). Si algo de las lecciones 03-05 quedó incompleto, esta es la lección donde se termina de ver junto, de principio a fin.


El servidor completo

# reservo_prompts_mcp_server.py
"""Servidor MCP de Reservo: handshake (M2) + prompts (M5).
El prompt ancla plan_booking se sirve por nombre, con un argumento opcional room.
Tools (M3) y resources (M4) no se implementan en este servidor -- el foco de M5 es prompts."""
import sys
import json

PROTOCOL_VERSION = "2025-06-18"
SERVER_INFO = {"name": "reservo-mcp-server", "version": "1.0.0"}

MCP_PROMPTS = [
    {
        "name": "plan_booking",
        "description": "Guide the assistant through checking policy and quoting before booking a room",
        "arguments": [
            {"name": "room", "description": "Room to plan a booking for", "required": False},
        ],
    },
]


def build_plan_booking_message(arguments: dict) -> str:
    room = arguments.get("room")
    room_phrase = f"the {room} room" if room else "whichever room I choose"
    return (
        f"I want to plan a booking for {room_phrase} at Reservo. Before booking "
        "anything, review the cancellation policy at "
        "reservo://policies/cancellation-policy so you can explain the refund "
        f"rules if I ask about them. Then get a price quote with get_quote for "
        f"{room_phrase} before doing anything else. Only call book_room after "
        "I have seen the quote and I have explicitly confirmed I want to proceed."
    )


PROMPT_BUILDERS = {"plan_booking": build_plan_booking_message}


def send(message: dict) -> None:
    """UN mensaje JSON-RPC por linea en stdout. Nunca un print() suelto aqui."""
    sys.stdout.write(json.dumps(message) + "\n")
    sys.stdout.flush()


def log(text: str) -> None:
    """Logs SIEMPRE a stderr -- stdout es exclusivo para mensajes MCP validos."""
    print(text, file=sys.stderr, flush=True)


def handle_initialize(msg_id, params: dict) -> dict:
    client_version = params.get("protocolVersion")
    if client_version is None:
        return {
            "jsonrpc": "2.0",
            "id": msg_id,
            "error": {"code": -32602, "message": "Invalid params: falta protocolVersion"},
        }
    log(f"[server] initialize <- cliente {params.get('clientInfo')}, protocolVersion={client_version}")
    return {
        "jsonrpc": "2.0",
        "id": msg_id,
        "result": {
            "protocolVersion": PROTOCOL_VERSION,
            "capabilities": {"tools": {}, "resources": {}, "prompts": {}},
            "serverInfo": SERVER_INFO,
        },
    }


def find_prompt(name):
    return next((prompt for prompt in MCP_PROMPTS if prompt["name"] == name), None)


def handle_prompts_list(msg_id) -> dict:
    log(f"[server] prompts/list -> {len(MCP_PROMPTS)} prompts")
    return {"jsonrpc": "2.0", "id": msg_id, "result": {"prompts": MCP_PROMPTS}}


def handle_prompts_get(msg_id, params: dict) -> dict:
    name = params.get("name")
    arguments = params.get("arguments", {})
    prompt = find_prompt(name)
    if prompt is None:
        return {
            "jsonrpc": "2.0",
            "id": msg_id,
            "error": {"code": -32602, "message": f"Unknown prompt: {name}"},
        }

    for arg_spec in prompt["arguments"]:
        if arg_spec["required"] and arg_spec["name"] not in arguments:
            return {
                "jsonrpc": "2.0",
                "id": msg_id,
                "error": {
                    "code": -32602,
                    "message": f"Missing required argument '{arg_spec['name']}' for prompt '{name}'",
                },
            }

    log(f"[server] prompts/get <- {name}({arguments})")
    text = PROMPT_BUILDERS[name](arguments)
    return {
        "jsonrpc": "2.0",
        "id": msg_id,
        "result": {
            "description": prompt["description"],
            "messages": [
                {"role": "user", "content": {"type": "text", "text": text}},
            ],
        },
    }


def main() -> None:
    log("[server] arrancando, esperando mensajes por stdin...")
    for raw_line in sys.stdin:
        line = raw_line.strip()
        if not line:
            continue
        message = json.loads(line)
        method = message.get("method")
        msg_id = message.get("id")
        params = message.get("params", {})

        if method == "initialize":
            send(handle_initialize(msg_id, params))
        elif method == "notifications/initialized":
            log("[server] notifications/initialized <- el cliente confirma que ya puede operar")
        elif method == "prompts/list":
            send(handle_prompts_list(msg_id))
        elif method == "prompts/get":
            send(handle_prompts_get(msg_id, params))
        elif msg_id is not None:
            send({
                "jsonrpc": "2.0",
                "id": msg_id,
                "error": {"code": -32601, "message": f"Method not found: {method}"},
            })
        else:
            log(f"[server] notificacion desconocida ignorada: {method}")


if __name__ == "__main__":
    main()

Compáralo con reservo_mcp_server_m2.py (el handshake solo, del Módulo 2): handle_initialize no cambió ni una línea. Lo nuevo es MCP_PROMPTS (el catálogo, con un solo prompt), build_plan_booking_message (la lógica de sustitución), PROMPT_BUILDERS (el registro {name: función}), find_prompt, handle_prompts_list, handle_prompts_get, y dos ramas más en el dispatch de main(). El mismo patrón exacto —agregar una estructura de datos, un handler, una rama de if/elif— que ya usaste para extender el servidor con tools (M3) y con resources (M4), ahora aplicado a prompts.


Desarmando build_plan_booking_message: la lógica de sustitución

def build_plan_booking_message(arguments: dict) -> str:
    room = arguments.get("room")
    room_phrase = f"the {room} room" if room else "whichever room I choose"
    return (
        f"I want to plan a booking for {room_phrase} at Reservo. Before booking "
        "anything, review the cancellation policy at "
        "reservo://policies/cancellation-policy so you can explain the refund "
        f"rules if I ask about them. Then get a price quote with get_quote for "
        f"{room_phrase} before doing anything else. Only call book_room after "
        "I have seen the quote and I have explicitly confirmed I want to proceed."
    )

Tres cosas para notar, en orden de importancia:

  1. La especificación de MCP no define ningún lenguaje de plantillas. No hay una sintaxis estándar tipo {{room}} que el protocolo interprete — arguments (un diccionario) le llega al servidor, y el servidor decide, con el código que quiera, cómo construir el texto final. Aquí es un f-string de Python con una rama condicional; podría ser, en otro servidor, una librería de templating (jinja2, por ejemplo), o una llamada a otro servicio que redacta el texto. MCP estandariza la forma del resultado (messages: [{role, content}]), no cómo se llega a ese resultado.

  2. room_phrase se calcula una sola vez y se reutiliza en los dos lugares donde importa. El texto menciona la sala dos veces —dónde planear la reserva, para qué pedir la cotización— y las dos menciones usan la misma variable, así que nunca pueden desincronizarse (no hay forma de que diga "the Focus room" en un lugar y "the Studio room" en otro, dentro del mismo mensaje).

  3. El resto del texto —la instrucción de revisar la política, cotizar antes de reservar, no llamar book_room sin confirmación— es literal, sin ninguna sustitución. Solo dos huecos de la plantilla dependen del argumento; todo lo demás es fijo, sin importar qué mande el usuario en room. Esto es intencional: el propósito del prompt (guiar un flujo prudente de reserva) no cambia según la sala; lo único que cambia es a qué sala se refiere concretamente.


Ejemplo trabajado: descubrir y activar, de punta a punta

# reservo_prompts_mcp_client.py
"""Cliente MCP de Reservo: handshake (M2) + prompts/list + prompts/get contra
reservo_prompts_mcp_server.py, por stdio real. IDs de JSON-RPC con itertools.count(1)."""
import subprocess
import sys
import json
import itertools

PROTOCOL_VERSION = "2025-06-18"
CLIENT_INFO = {"name": "reservo-mcp-client", "version": "1.0.0"}

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


def send(message: dict) -> None:
    line = json.dumps(message)
    print(f"[client -> server] {line}")
    proc.stdin.write(line + "\n")
    proc.stdin.flush()


def recv() -> dict:
    line = proc.stdout.readline().strip()
    print(f"[server -> client] {line}")
    return json.loads(line)


# 1) Handshake (M2) -- igual en todos los modulos.
send({
    "jsonrpc": "2.0", "id": next(request_ids), "method": "initialize",
    "params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {}, "clientInfo": CLIENT_INFO},
})
recv()
send({"jsonrpc": "2.0", "method": "notifications/initialized"})

# 2) prompts/list -- descubrir el catalogo de plantillas.
send({"jsonrpc": "2.0", "id": next(request_ids), "method": "prompts/list"})
prompts_response = recv()
for prompt in prompts_response["result"]["prompts"]:
    print(f"[client] prompt disponible -> {prompt['name']}: {prompt['description']}")

# 3) prompts/get -- con el argumento room.
send({"jsonrpc": "2.0", "id": next(request_ids), "method": "prompts/get",
      "params": {"name": "plan_booking", "arguments": {"room": "Focus"}}})
with_room_response = recv()

# 4) prompts/get -- sin el argumento room (es opcional).
send({"jsonrpc": "2.0", "id": next(request_ids), "method": "prompts/get",
      "params": {"name": "plan_booking", "arguments": {}}})
without_room_response = recv()

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

Qué esperar (la corrida completa, byte a byte):

[client -> server] {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "reservo-mcp-client", "version": "1.0.0"}}}
[server -> 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 -> server] {"jsonrpc": "2.0", "method": "notifications/initialized"}
[client -> server] {"jsonrpc": "2.0", "id": 2, "method": "prompts/list"}
[server -> client] {"jsonrpc": "2.0", "id": 2, "result": {"prompts": [{"name": "plan_booking", "description": "Guide the assistant through checking policy and quoting before booking a room", "arguments": [{"name": "room", "description": "Room to plan a booking for", "required": false}]}]}}
[client] prompt disponible -> plan_booking: Guide the assistant through checking policy and quoting before booking a room
[client -> server] {"jsonrpc": "2.0", "id": 3, "method": "prompts/get", "params": {"name": "plan_booking", "arguments": {"room": "Focus"}}}
[server -> client] {"jsonrpc": "2.0", "id": 3, "result": {"description": "Guide the assistant through checking policy and quoting before booking a room", "messages": [{"role": "user", "content": {"type": "text", "text": "I want to plan a booking for the Focus room at Reservo. Before booking anything, review the cancellation policy at reservo://policies/cancellation-policy so you can explain the refund rules if I ask about them. Then get a price quote with get_quote for the Focus room before doing anything else. Only call book_room after I have seen the quote and I have explicitly confirmed I want to proceed."}}]}}
[client -> server] {"jsonrpc": "2.0", "id": 4, "method": "prompts/get", "params": {"name": "plan_booking", "arguments": {}}}
[server -> client] {"jsonrpc": "2.0", "id": 4, "result": {"description": "Guide the assistant through checking policy and quoting before booking a room", "messages": [{"role": "user", "content": {"type": "text", "text": "I want to plan a booking for whichever room I choose at Reservo. Before booking anything, review the cancellation policy at reservo://policies/cancellation-policy so you can explain the refund rules if I ask about them. Then get a price quote with get_quote for whichever room I choose before doing anything else. Only call book_room after I have seen the quote and I have explicitly confirmed I want to proceed."}}]}}

Siete mensajes JSON-RPC en total: dos del handshake (initialize y su response), una notification (notifications/initialized, sin response), y cuatro del descubrimiento y activación del prompt (prompts/list + su response, dos prompts/get + sus dos responses). El id avanza sin saltos ni repeticiones: 1 (initialize), 2 (prompts/list), 3 y 4 (los dos prompts/get) — la notification nunca consume un id, el mismo comportamiento que ya viste desde el Módulo 2.

Compara las dos respuestas de prompts/get línea por línea: todo es idéntico entre ambas —description, la estructura de messages, el role— salvo la frase "the Focus room" contra "whichever room I choose", que aparece exactamente dos veces en cada texto, en las mismas dos posiciones relativas de la oración. Eso confirma en la práctica lo que la sección anterior explicó en código: room_phrase se calcula una vez y se sustituye en los dos lugares que dependen de él, sin tocar el resto de la instrucción.


Lo que el prompt NO hace (y por qué eso es correcto)

plan_booking menciona reservo://policies/cancellation-policy (un resource de M4) y get_quote/book_room (tools de M3) — pero no lee el resource, no llama las tools, no verifica nada por su cuenta. El texto generado es una instrucción, dirigida a quien reciba este mensaje, pidiéndole que él mismo revise la política y pida la cotización antes de reservar. El servidor de Reservo de este módulo, de hecho, ni siquiera implementa tools ni resources —recuérdalo de la lección 01: cada módulo de esta guía mantiene su servidor enfocado en un solo primitivo—, así que sería imposible que plan_booking "ejecutara" algo aunque quisiera.

Esto es coherente con la definición de la lección 02: un prompt es user-controlled en su activación, pero una vez activado, produce texto — no acciones. Las acciones (llamar get_quote, leer la política, llamar book_room) siguen siendo responsabilidad de quien reciba y actúe sobre ese texto, típicamente un modelo, en una conversación real, usando el protocolo tool_use/tool_result de la Messages API (nombrado, no reimplementado — la frontera de la lección 05). El Módulo 8, el capstone, es donde ves el servidor completo de Reservo con tools, resources y prompts coexistiendo en el mismo proceso, listo para que un cliente los use juntos en una sola conversación.


Errores comunes

  1. Esperar que plan_booking valide que la sala mencionada exista. No lo hace, y a propósito: room no tiene enum (a diferencia del room de get_quote en el inputSchema de una tool, Módulo 3, lección 03) porque el argumento de un prompt no es una llamada a función que necesite ese rigor — es texto libre que se sustituye en una plantilla. Si mandas {"room": "Ballroom"} (una sala que no existe en Reservo), el prompt genera igual un texto coherente mencionando "the Ballroom room" — el error, si lo hay, lo detectaría después quien intente cotizar esa sala con get_quote, no prompts/get.

  2. Pensar que el orden de prompts/list antes de prompts/get es obligatorio. No lo es —igual que con resources/read en el Módulo 4—: si ya sabes que el prompt se llama "plan_booking", puedes llamar prompts/get directamente, sin listar primero. prompts/list existe para descubrir el catálogo cuando no lo conoces de antemano, no como un paso obligatorio del protocolo.

  3. Copiar el texto generado y tratarlo como si fuera inmutable entre ejecuciones. El texto de plan_booking es determinista (mismo arguments → mismo text, siempre, en este servidor) porque build_plan_booking_message no usa ningún dato variable como fecha u hora — pero eso es una decisión de diseño de Reservo, no una garantía del protocolo. Un servidor MCP distinto podría, legítimamente, generar texto distinto en cada llamada (por ejemplo, si personaliza el mensaje con datos que cambian). No asumas determinismo de un servidor de terceros sin confirmarlo.


Ejercicios

Ejercicio 1: Rastrea la sustitución (Fácil)

Sin ejecutar nada: en el texto generado con room: "Boardroom", ¿en qué dos frases exactas aparecería "the Boardroom room"? Escríbelas completas, basándote en la plantilla de build_plan_booking_message.

Ver solución
  1. "I want to plan a booking for the Boardroom room at Reservo."
  2. "Then get a price quote with get_quote for the Boardroom room before doing anything else."

Las dos usan room_phrase, calculado una sola vez como "the Boardroom room" cuando arguments.get("room") devuelve "Boardroom". El resto del texto —"Before booking anything, review the cancellation policy...", "Only call book_room after..."— no contiene la palabra "Boardroom" en ningún lugar.

Ejercicio 2: Cambia la sala y confirma con código (Medio)

Ejecuta prompts/get para plan_booking con {"room": "Studio"}, y escribe dos assert sobre el texto recibido: uno que confirme que contiene "the Studio room" exactamente dos veces, y otro que confirme que no contiene la palabra "Focus" en ningún lugar.

Ver solución
import subprocess, sys, json, itertools

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

def send(message):
    proc.stdin.write(json.dumps(message) + "\n")
    proc.stdin.flush()

def recv():
    return json.loads(proc.stdout.readline())

send({"jsonrpc": "2.0", "id": next(ids), "method": "initialize",
      "params": {"protocolVersion": "2025-06-18", "capabilities": {},
                 "clientInfo": {"name": "reservo-mcp-client", "version": "1.0.0"}}})
recv()
send({"jsonrpc": "2.0", "method": "notifications/initialized"})

send({"jsonrpc": "2.0", "id": next(ids), "method": "prompts/get",
      "params": {"name": "plan_booking", "arguments": {"room": "Studio"}}})
response = recv()
text = response["result"]["messages"][0]["content"]["text"]

assert text.count("the Studio room") == 2
assert "Focus" not in text
print("OK: 'the Studio room' aparece 2 veces, 'Focus' no aparece")

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

Salida esperada:

OK: 'the Studio room' aparece 2 veces, 'Focus' no aparece

Explicación: str.count confirma con precisión el número exacto de repeticiones, en vez de solo verificar que la frase "aparece" (lo cual sería cierto incluso si apareciera una vez de más o de menos por error). El segundo assert confirma que no queda ningún resto de una sala distinta —un tipo de bug real que podría ocurrir si, por ejemplo, room_phrase se calculara mal o se reutilizara una variable de una llamada anterior por error (algo que no pasa aquí, porque cada llamada a build_plan_booking_message recibe sus propios arguments y no depende de ningún estado compartido entre llamadas).

Ejercicio 3: Agrega un segundo argumento a plan_booking (Difícil)

Extiende plan_booking con un segundo argumento opcional, tier ("Membership tier (basic or pro) to plan the booking under", required: False). Modifica build_plan_booking_message para que, si tier está presente, agregue una frase adicional al final del texto mencionando que se debe considerar el descuento de pro si corresponde (sin tocar el resto de la plantilla). Ejecuta prompts/get con {"room": "Focus", "tier": "pro"} y confirma que el texto incluye la frase nueva; ejecuta de nuevo con solo {"room": "Focus"} (sin tier) y confirma que el texto es idéntico al de antes de este ejercicio.

Ver solución
MCP_PROMPTS[0]["arguments"].append(
    {"name": "tier", "description": "Membership tier (basic or pro) to plan the booking under", "required": False}
)


def build_plan_booking_message(arguments: dict) -> str:
    room = arguments.get("room")
    tier = arguments.get("tier")
    room_phrase = f"the {room} room" if room else "whichever room I choose"
    text = (
        f"I want to plan a booking for {room_phrase} at Reservo. Before booking "
        "anything, review the cancellation policy at "
        "reservo://policies/cancellation-policy so you can explain the refund "
        f"rules if I ask about them. Then get a price quote with get_quote for "
        f"{room_phrase} before doing anything else. Only call book_room after "
        "I have seen the quote and I have explicitly confirmed I want to proceed."
    )
    if tier:
        text += f" Remember to account for the {tier} membership discount, if any, when quoting."
    return text

Con {"room": "Focus", "tier": "pro"}, el texto termina con:

... Only call book_room after I have seen the quote and I have explicitly confirmed I want to proceed. Remember to account for the pro membership discount, if any, when quoting.

Con solo {"room": "Focus"} (sin tier), el texto es exactamente el mismo que antes de este ejercicio —sin la frase extra—, confirmando que if tier: solo se activa cuando el argumento está presente.

Explicación: este es el mismo patrón de "argumento opcional, rama condicional" que ya usa room, aplicado una segunda vez — cada argumento adicional de un prompt es, en el código del servidor, una comprobación más (arguments.get(...)) y, opcionalmente, una rama que modifica el texto solo si el dato está presente. No hace falta ningún mecanismo nuevo del protocolo para agregar un segundo argumento: PromptArgument ya está diseñado para declarar tantos como el prompt necesite, cada uno independientemente opcional o requerido.


Resumen y siguiente paso

  • plan_booking completo: catálogo (MCP_PROMPTS), lógica de sustitución (build_plan_booking_message), y los dos handlers (prompts/list, prompts/get) conectados al mismo dispatch que ya conoces desde el Módulo 2.
  • La sustitución de argumentos no está definida por MCP — es lógica de aplicación, escrita como código Python normal (aquí, un f-string con una rama condicional), no un lenguaje de plantillas del protocolo.
  • Ejecutamos el flujo completo —initializenotifications/initializedprompts/list → dos prompts/get— y confirmamos, byte a byte, que el texto cambia solo donde depende de room.
  • plan_booking instruye, no ejecuta: menciona un resource (M4) y dos tools (M3) en su texto, pero no los llama — esa responsabilidad es de quien reciba y actúe sobre el mensaje, típicamente un modelo en una conversación real.

Siguiente lección: 07 — Tools, resources y prompts: los tres modelos de interacción. Cierra el cuadro completo: los tres primitivos de MCP, lado a lado, con el criterio exacto para elegir cuál usar en cada situación nueva.


Recursos adicionales

  1. Model Context Protocol — Specification 2025-06-18: Prompts — La especificación completa de prompts/list y prompts/get, implementada en su totalidad en esta lección.
  2. Model Context Protocol — Specification 2025-06-18: Resources — El resource reservo://policies/cancellation-policy que el prompt menciona, construido en el Módulo 4.
  3. Model Context Protocol — Specification 2025-06-18: Tools — Las tools get_quote/book_room que el prompt menciona, construidas en el Módulo 3.
  4. Python — f-strings — El mecanismo de sustitución usado en build_plan_booking_message, sin ningún lenguaje de plantillas adicional.