Module 5: Prompts — Reusable Templates

The `plan_booking` prompt for Reservo

Description

Lessons 03, 04, and 05 gave you plan_booking in pieces: first as a catalog entry, then activated with and without its argument, then with its PromptMessage taken apart field by field. This lesson joins the three pieces in the complete server, run end to end in a single pass, and stops at the detail the previous lessons used but didn't fully explain: how the text gets built from the arguments — the substitution logic that turns "an optional argument called room" into a coherent prose instruction, whether or not a room was specified.

Connection to the module

This is the module's synthesis lesson, in the same place where Module 2 had its "complete handshake, executed" (lesson 07) and Module 3 had its tools/call over the four canonical tools (lesson 05). If anything from lessons 03-05 was left incomplete, this is the lesson where it all gets seen together, start to finish.


The complete server

# reservo_prompts_mcp_server.py
"""Reservo MCP server: handshake (M2) + prompts (M5).
The anchor prompt plan_booking is served by name, with an optional room argument.
Tools (M3) and resources (M4) are not implemented in this server -- M5's focus is 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:
    """ONE JSON-RPC message per line on stdout. Never a stray print() here."""
    sys.stdout.write(json.dumps(message) + "\n")
    sys.stdout.flush()


def log(text: str) -> None:
    """Logs ALWAYS go to stderr -- stdout is exclusive for valid MCP messages."""
    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: missing protocolVersion"},
        }
    log(f"[server] initialize <- client {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] starting up, waiting for messages on 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 <- client confirms it can now operate")
        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] unknown notification ignored: {method}")


if __name__ == "__main__":
    main()

Compare it with reservo_mcp_server_m2.py (the handshake alone, from Module 2): handle_initialize didn't change a single line. What's new is MCP_PROMPTS (the catalog, with a single prompt), build_plan_booking_message (the substitution logic), PROMPT_BUILDERS (the {name: function} registry), find_prompt, handle_prompts_list, handle_prompts_get, and two more branches in main()'s dispatch. The exact same pattern — add a data structure, a handler, an if/elif branch — that you already used to extend the server with tools (M3) and with resources (M4), now applied to prompts.


Taking apart build_plan_booking_message: the substitution logic

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."
    )

Three things worth noting, in order of importance:

  1. The MCP specification doesn't define any templating language. There's no standard {{room}}-style syntax that the protocol interprets — arguments (a dictionary) arrives at the server, and the server decides, with whatever code it wants, how to build the final text. Here it's a Python f-string with a conditional branch; on a different server it could be a templating library (jinja2, for example), or a call to another service that drafts the text. MCP standardizes the shape of the result (messages: [{role, content}]), not how you get to that result.

  2. room_phrase is computed once and reused in the two places where it matters. The text mentions the room twice — where to plan the booking, what to get a quote for — and both mentions use the same variable, so they can never fall out of sync (there's no way it could say "the Focus room" in one place and "the Studio room" in another, within the same message).

  3. The rest of the text — the instruction to review the policy, get a quote before booking, not call book_room without confirmation — is literal, with no substitution at all. Only two gaps in the template depend on the argument; everything else is fixed, no matter what the user sends in room. This is intentional: the purpose of the prompt (guiding a careful booking flow) doesn't change depending on the room; the only thing that changes is which room it concretely refers to.


Worked example: discover and activate, end to end

# reservo_prompts_mcp_client.py
"""Reservo MCP client: handshake (M2) + prompts/list + prompts/get against
reservo_prompts_mcp_server.py, over real stdio. JSON-RPC ids with 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) -- same in every module.
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 -- discover the catalog of templates.
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 available -> {prompt['name']}: {prompt['description']}")

# 3) prompts/get -- with the room argument.
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 -- without the room argument (it's optional).
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)

What to expect (the complete run, byte for 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 available -> 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."}}]}}

Seven JSON-RPC messages in total: two from the handshake (initialize and its response), one notification (notifications/initialized, no response), and four from discovering and activating the prompt (prompts/list + its response, two prompts/get + their two responses). The id advances without gaps or repeats: 1 (initialize), 2 (prompts/list), 3 and 4 (the two prompts/get) — the notification never consumes an id, the same behavior you've already seen since Module 2.

Compare the two prompts/get responses line by line: everything is identical between them — description, the shape of messages, role — except the phrase "the Focus room" versus "whichever room I choose", which appears exactly twice in each text, in the same two relative positions of the sentence. That confirms in practice what the previous section explained in code: room_phrase is computed once and substituted in the two places that depend on it, without touching the rest of the instruction.


What the prompt does NOT do (and why that's correct)

plan_booking mentions reservo://policies/cancellation-policy (a resource from M4) and get_quote/book_room (tools from M3) — but it doesn't read the resource, doesn't call the tools, doesn't verify anything on its own. The generated text is an instruction, addressed to whoever receives this message, asking them to review the policy and get the quote before booking. This module's Reservo server, in fact, doesn't even implement tools or resources — remember lesson 01: every module in this guide keeps its server focused on a single primitive — so it would be impossible for plan_booking to "execute" anything even if it wanted to.

This is consistent with lesson 02's definition: a prompt is user-controlled in its activation, but once activated, it produces text — not actions. The actions (calling get_quote, reading the policy, calling book_room) remain the responsibility of whoever receives and acts on that text, typically a model, in a real conversation, using the tool_use/tool_result protocol of the Messages API (named, not reimplemented — lesson 05's boundary). Module 8, the capstone, is where you see the complete Reservo server with tools, resources, and prompts coexisting in the same process, ready for a client to use them together in a single conversation.


Common mistakes

  1. Expecting plan_booking to validate that the mentioned room exists. It doesn't, on purpose: room has no enum (unlike get_quote's room in a tool's inputSchema, Module 3, lesson 03) because a prompt's argument isn't a function call that needs that rigor — it's free text substituted into a template. If you send {"room": "Ballroom"} (a room that doesn't exist at Reservo), the prompt still generates coherent text mentioning "the Ballroom room" — the error, if any, would be caught later by whoever tries to quote that room with get_quote, not by prompts/get.

  2. Thinking that calling prompts/list before prompts/get is mandatory. It isn't — same as with resources/read in Module 4 — if you already know the prompt is called "plan_booking", you can call prompts/get directly, without listing first. prompts/list exists to discover the catalog when you don't already know it, not as a mandatory protocol step.

  3. Copying the generated text and treating it as immutable across runs. plan_booking's text is deterministic (same arguments → same text, always, in this server) because build_plan_booking_message doesn't use any variable data like a date or time — but that's a design decision of Reservo's, not a guarantee of the protocol. A different MCP server could, legitimately, generate different text on each call (for example, if it personalizes the message with data that changes). Don't assume determinism from a third-party server without confirming it.


Exercises

Exercise 1: Trace the substitution (Easy)

Without running anything: in the text generated with room: "Boardroom", in which two exact sentences would "the Boardroom room" appear? Write them out in full, based on the build_plan_booking_message template.

See solution
  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."

Both use room_phrase, computed once as "the Boardroom room" when arguments.get("room") returns "Boardroom". The rest of the text — "Before booking anything, review the cancellation policy...", "Only call book_room after..." — doesn't contain the word "Boardroom" anywhere.

Exercise 2: Change the room and confirm with code (Medium)

Run prompts/get for plan_booking with {"room": "Studio"}, and write two assert statements about the received text: one confirming it contains "the Studio room" exactly twice, and another confirming it does not contain the word "Focus" anywhere.

See solution
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' appears 2 times, 'Focus' does not appear")

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

Expected output:

OK: 'the Studio room' appears 2 times, 'Focus' does not appear

Explanation: str.count precisely confirms the exact number of repetitions, instead of just verifying that the phrase "appears" (which would be true even if it appeared one time too many or too few by mistake). The second assert confirms that no trace of a different room is left over — a real kind of bug that could occur if, for example, room_phrase were computed incorrectly or a variable were reused from a previous call by mistake (which doesn't happen here, because each call to build_plan_booking_message receives its own arguments and doesn't depend on any shared state between calls).

Exercise 3: Add a second argument to plan_booking (Hard)

Extend plan_booking with a second optional argument, tier ("Membership tier (basic or pro) to plan the booking under", required: False). Modify build_plan_booking_message so that, if tier is present, it appends an extra sentence at the end of the text mentioning that the pro discount should be considered if applicable (without touching the rest of the template). Run prompts/get with {"room": "Focus", "tier": "pro"} and confirm the text includes the new sentence; run it again with just {"room": "Focus"} (no tier) and confirm the text is identical to what it was before this exercise.

See solution
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

With {"room": "Focus", "tier": "pro"}, the text ends with:

... 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.

With just {"room": "Focus"} (no tier), the text is exactly the same as before this exercise — without the extra sentence — confirming that if tier: only fires when the argument is present.

Explanation: this is the same "optional argument, conditional branch" pattern already used by room, applied a second time — every additional argument of a prompt is, in the server's code, one more check (arguments.get(...)) and, optionally, a branch that modifies the text only if the data is present. No new protocol mechanism is needed to add a second argument: PromptArgument is already designed to declare as many as the prompt needs, each independently optional or required.


Summary and next step

  • Complete plan_booking: catalog (MCP_PROMPTS), substitution logic (build_plan_booking_message), and the two handlers (prompts/list, prompts/get) plugged into the same dispatch you already know from Module 2.
  • Argument substitution is not defined by MCP — it's application logic, written as ordinary Python code (here, an f-string with a conditional branch), not a protocol templating language.
  • We ran the complete flow — initializenotifications/initializedprompts/list → two prompts/get — and confirmed, byte for byte, that the text changes only where it depends on room.
  • plan_booking instructs, it doesn't execute: it mentions a resource (M4) and two tools (M3) in its text, but doesn't call them — that responsibility belongs to whoever receives and acts on the message, typically a model in a real conversation.

Next lesson: 07 — Tools, resources, and prompts: the three interaction models. Closes the full picture: MCP's three primitives, side by side, with the exact criterion for choosing which one to use in any new situation.


Additional resources

  1. Model Context Protocol — Specification 2025-06-18: Prompts — The complete specification of prompts/list and prompts/get, fully implemented in this lesson.
  2. Model Context Protocol — Specification 2025-06-18: Resources — The reservo://policies/cancellation-policy resource the prompt mentions, built in Module 4.
  3. Model Context Protocol — Specification 2025-06-18: Tools — The get_quote/book_room tools the prompt mentions, built in Module 3.
  4. Python — f-strings — The substitution mechanism used in build_plan_booking_message, with no additional templating language.