Module 5: Prompts — Reusable Templates
`prompts/list` with arguments
Description
You already know what a prompt is. This lesson shows you the first real message a client sends to discover them: prompts/list. It's, once again, the same pattern you've already seen twice — tools/list in Module 3, resources/list in Module 4 —: a request with almost no parameters that returns a complete catalog. What's new here is a field neither tools nor resources have in their listing shape: each Prompt carries its own list of arguments, directly in the prompts/list response — you don't need a second message to know what arguments a prompt accepts, unlike a tool, where the full inputSchema also travels in tools/list, but with a considerably more elaborate structure.
You're going to extend Reservo's server by adding the prompts capability with its first method, declare plan_booking as a full Prompt object, and run prompts/list for real, over stdio.
Connection to the module
This lesson builds the first real piece of the reservo_prompts_mcp_server.py server you'll use throughout the rest of the module: the MCP_PROMPTS catalog and the prompts/list handler. Lesson 04 adds the second piece (prompts/get) on top of this same base.
Analogy: the notebook's table of contents
Picking up the analogy from lesson 02: before the customer service agent can choose a form letter from the notebook, they have to see the index — the titles of the available letters, and what data each one needs to be completed ("apology letter: needs the customer's name and the incident date"). prompts/list is exactly that index: a catalog with the name of each template and what arguments it accepts, without any letter's full content yet — that only arrives once one is chosen, with prompts/get (lesson 04).
The exact shape of the message
A prompts/list request, just like tools/list and resources/list, needs almost no parameters:
{"jsonrpc": "2.0", "id": 2, "method": "prompts/list"}
And the response carries result.prompts, an array of Prompt objects. Each one has this shape:
{
"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
}
]
}
Three fields, and one of them — arguments — is what sets Prompt's shape apart from Tool's:
name— the exact identifier, the one later used inprompts/get. Just as strict as a tool'sname: it must match character for character.description— a sentence that tells whoever is choosing from the catalog (typically shown to the user in a host's interface) what this template is for. Note: unlike a tool'sdescription— directed at the model, so it can decide when to invoke it — a prompt'sdescriptionis, in practice, directed more at the user choosing from the menu, consistent with being user-controlled.arguments— an array ofPromptArgumentobjects, each withname,description, andrequired(boolean). Notype, noenum, none of the rest of the JSON Schema vocabulary that a tool'sinputSchemadoes have (Module 3, lesson 04) — a deliberately simpler shape, because it doesn't validate a function call, it only documents which blanks in the template can be filled in.
Declaring plan_booking as an MCP object
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},
],
},
]
Notice the boolean: required: False in Python serializes as "required": false in JSON — lowercase, no quotes, the JSON boolean literal, not the string "false". It's an easy detail to overlook if you're used to writing a lot of JSON by hand instead of letting json.dumps do it for you.
The handler that responds to prompts/list, with the catalog already built, is as simple as tools/list's (Module 3, lesson 03):
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}}
And it plugs into the server's dispatch with one more branch:
elif method == "prompts/list":
send(handle_prompts_list(msg_id))
Worked example: prompts/list, against two servers
To see the capability in action — the same comparison pattern you already used in Module 3, lesson 02 — we send the same prompts/list request against the Module 2 server (which declares "prompts": {} in its capabilities, but never implemented the method) and against this module's server:
# before_after_prompts_client.py
"""Compares prompts/list against the M2 server (no prompts capability
implemented) and against the M5 server (with real prompts/list and
prompts/get)."""
import subprocess
import sys
import json
import itertools
def run_prompts_list(server_file):
ids = itertools.count(1)
proc = subprocess.Popen(
[sys.executable, server_file],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1,
)
init = {"jsonrpc": "2.0", "id": next(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(ids), "method": "prompts/list"}
print(f"[client -> server] {json.dumps(request)}")
proc.stdin.write(json.dumps(request) + "\n")
proc.stdin.flush()
line = proc.stdout.readline().strip()
print(f"[server -> client] {line}")
proc.stdin.close()
proc.wait(timeout=5)
print("=== against reservo_mcp_server_m2.py (handshake only, no prompts) ===")
run_prompts_list("reservo_mcp_server_m2.py")
print()
print("=== against reservo_prompts_mcp_server.py (M5: real prompts capability) ===")
run_prompts_list("reservo_prompts_mcp_server.py")
What to expect:
=== against reservo_mcp_server_m2.py (handshake only, no prompts) ===
[client -> server] {"jsonrpc": "2.0", "id": 2, "method": "prompts/list"}
[server -> client] {"jsonrpc": "2.0", "id": 2, "error": {"code": -32601, "message": "Method not found: prompts/list"}}
=== against reservo_prompts_mcp_server.py (M5: real prompts capability) ===
[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}]}]}}
Exactly the same contrast you already saw with tools and resources: both servers declared "prompts": {} in their initialize capabilities (inherited from the same block across all three modules), but only one of the two implemented the method. Declaring and fulfilling are still two different things, no matter which primitive is involved — Module 3, lesson 02 already established why the protocol doesn't force that consistency.
Reading the response field by field
# read_prompts_catalog.py
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/list"})
response = recv()
for prompt in response["result"]["prompts"]:
print(f"prompt: {prompt['name']}")
print(f" description: {prompt['description']}")
for arg in prompt["arguments"]:
required = "required" if arg["required"] else "optional"
print(f" argument {arg['name']!r} ({required}): {arg['description']}")
proc.stdin.close()
proc.wait(timeout=5)
What to expect:
prompt: plan_booking
description: Guide the assistant through checking policy and quoting before booking a room
argument 'room' (optional): Room to plan a booking for
A single prompt, a single argument, optional. This catalog will only grow if Reservo adds more templates — just as the tools or resources catalog only grows if more entries are added to the corresponding dictionary or list; prompts/list doesn't execute anything, doesn't change any state, it only enumerates what's there.
Common mistakes
-
Expecting
argumentsto have the shape of aninputSchema. It doesn't.argumentsis a flat list of{name, description, required}objects — notype, no nestedproperties, noenum. If you write code that tries to readprompt["arguments"]["properties"](as you would with a tool'sinputSchema), you'll get an error, becauseargumentsis already the list itself, not an object containing it. -
Confusing
required: false(JSON) withNone/null. An optional argument still hasrequiredpresent in the JSON, with valuefalse— not absent, notnull. The total absence of the argument inarguments(inprompts/get) is what corresponds to "I didn't send it";required: falseis a property of the catalog, not of the concrete request. -
Assuming the order of
argumentsmatters forprompts/get. No — each argument is identified by itsnamewithin an object ({"room": "Focus"}), not by position. The order they appear in inprompts/listis only the server's declaration order, with no additional meaning.
Exercises
Exercise 1: Read the catalog (Easy)
Given this prompts/list response, answer: how many prompts does the server offer? How many arguments does the second one have, and which of them is required?
{"jsonrpc": "2.0", "id": 5, "result": {"prompts": [
{"name": "plan_booking", "description": "...", "arguments": [
{"name": "room", "description": "...", "required": false}
]},
{"name": "draft_cancellation_email", "description": "...", "arguments": [
{"name": "booking_id", "description": "...", "required": true},
{"name": "reason", "description": "...", "required": false}
]}
]}}
See solution
- Number of prompts: 2 (
plan_booking,draft_cancellation_email). - Arguments of the second one: 2 (
booking_id,reason). - Required:
booking_id(required: true) —reasonis optional (required: false).
Exercise 2: Add a second prompt to the catalog (Medium)
Extend this lesson's server's MCP_PROMPTS with a second prompt: name: "list_rooms_prompt", description: "Ask the assistant to list every room Reservo offers, with prices", and arguments: [] (an empty list — a legitimate prompt can have no arguments at all). You don't need to implement prompts/get for this new prompt yet (that's lesson 04). Run prompts/list and confirm that two prompts now appear.
See solution
MCP_PROMPTS.append({
"name": "list_rooms_prompt",
"description": "Ask the assistant to list every room Reservo offers, with prices",
"arguments": [],
})
Running prompts/list again:
{"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}]},
{"name": "list_rooms_prompt", "description": "Ask the assistant to list every room Reservo offers, with prices", "arguments": []}
]}}
Explanation: handle_prompts_list iterates over MCP_PROMPTS with no fixed expected count, exactly like handle_tools_list (Module 3) and handle_resources_list (Module 4) — adding an entry to the list is enough for it to show up in the response, without touching the handler. arguments: [] is a valid empty list: a prompt can need no additional data and still be a useful template (in this case, a fixed instruction: "list Reservo's rooms with their prices").
Exercise 3: Compare the size of arguments against an equivalent inputSchema (Hard)
Take plan_booking's room argument and its closest equivalent within get_quote's inputSchema (Module 3, lesson 03): the room property, with type: "string" and enum: ["Focus", "Studio", "Boardroom"]. Using json.dumps, calculate how many bytes each representation takes up separately, and explain in one sentence why the arguments one is smaller — beyond the difference in content, what information does one have that the other doesn't, structurally?
See solution
import json
prompt_argument_room = {"name": "room", "description": "Room to plan a booking for", "required": False}
tool_schema_property_room = {"type": "string", "enum": ["Focus", "Studio", "Boardroom"]}
print("bytes of the 'room' PromptArgument:", len(json.dumps(prompt_argument_room)))
print("bytes of the 'room' inputSchema property:", len(json.dumps(tool_schema_property_room)))
Expected output:
bytes of the 'room' PromptArgument: 68
bytes of the 'room' inputSchema property: 53
Explanation: the PromptArgument ends up bigger in bytes in this specific case (because it includes name and description, while the inputSchema property doesn't repeat the name, since it's already the key of the dictionary containing it) — but the real structural difference isn't about size, it's about expressiveness: the inputSchema property declares which values are valid (enum with the three exact rooms, type: "string" that the server can use to validate — Module 3, lesson 05), while room's PromptArgument restricts nothing — any string would be accepted as the value of room in prompts/get, because the purpose isn't to validate a function call with precision, it's to document which blank in the template exists. This is the same underlying distinction lesson 02 already previewed: a prompt's arguments are deliberately less strict than a tool's inputSchema.
Summary and next step
prompts/listis a request with almost no parameters that returnsresult.prompts, a complete array ofPromptobjects — the same catalog pattern astools/listandresources/list.- Each
Prompthasname,description, andarguments— a flat list of{name, description, required}, with notypeorenum, deliberately simpler than a tool'sinputSchema. - We ran the full request against
reservo_prompts_mcp_server.pyand confirmedplan_bookingarrives with its single argument,room, marked optional. - Declaring the
promptscapability ininitializeand implementingprompts/listremain two different things — the same pattern you already saw with tools and resources.
Next lesson: 04 — prompts/get. The method that activates a prompt: request it by name, with or without arguments, and get its messages back — including the two errors it can produce.
Additional resources
- Model Context Protocol — Specification 2025-06-18: Prompts — Listing Prompts — The exact shape of
prompts/list, thePromptobject, andPromptArgument. - Model Context Protocol — Specification 2025-06-18: Tools — Listing Tools — The full
inputSchema, to compare againstarguments's simpler shape. - Python —
json— How Python serializesTrue/Falseastrue/falsein JSON, the detail flagged in this lesson. - Python —
subprocess— The foundation of the client that discovers the catalog in the worked example.