Module 4: Resources — context and data
Mini-project: Reservo's policies as resources
Description
The previous seven lessons built the module piece by piece: what a resource is, how it's discovered (resources/list), how its URI is designed, how it's read (resources/read), its two content formats (text/blob), and when to choose it over a tool. This mini-project asks you to bring it all together in a single server and a single client, with your own hands, and verify it with a programmatic checklist — not just reading the code, confirming it with assert.
It's the literal base Module 5 stands on: when you add prompts/list/prompts/get there, you're going to do it by extending a server that already knows how to do the handshake (M2) and resolve resources (M4) — exactly as this mini-project leaves it.
Connection to the module
It closes Module 4 by putting into your hands, end to end, the three pieces you've already seen run separately: the catalog (resources/list, lesson 03), reading by URI (resources/read, lesson 05), and handling the error when the URI doesn't exist (-32002, also lesson 05). If anything about this mini-project gives you trouble, that's the clearest signal for which of the previous seven lessons is worth reviewing before moving on to Module 5.
The goal
Build (or complete, if you start from Module 2's server) reservo_mcp_server.py and reservo_mcp_client.py that, between the two of them, complete: the handshake (initialize/notifications/initialized, from Module 2), resources/list (returning Reservo's two anchor resources), and resources/read (reading either one by its URI, and returning the correct error if a nonexistent URI is requested). This guide's fixed names and version, as always: server reservo-mcp-server, client reservo-mcp-client, both version: "1.0.0", protocolVersion: "2025-06-18".
Mini-project constraints:
- The server must declare
resourcesin itsinitializecapabilities(it already did since Module 2, alongsidetoolsandprompts— unchanged). - Both anchor resources, exact:
reservo://policies/cancellation-policyandreservo://policies/membership-tiers, bothmimeType: "text/markdown". resources/readwith aurinot in the catalog must respond-32002 Resource not found, withdata.uriindicating which one failed — not an uncontrolled Python exception, not a silent message.stdoutremains the server's exclusive territory for JSON-RPC messages — Module 2's hard rule isn't up for negotiation.- IDs via
itertools.count(1), neverrandom/uuid.
Reference solution: the complete server
# reservo_mcp_server.py
"""Reservo MCP server: handshake (M2) + resources (M4).
Reservo's 2 policy resources are served by fixed URI, no search.
Tools (M3) and prompts (M5) aren't implemented on this server -- M4's focus is resources."""
import sys
import json
PROTOCOL_VERSION = "2025-06-18"
SERVER_INFO = {"name": "reservo-mcp-server", "version": "1.0.0"}
CANCELLATION_POLICY_TEXT = """# Cancellation Policy
Reservo bookings can be cancelled free of charge up to 24 hours before the reserved start time. Cancellations made within this window are refunded in full, with no penalty applied to the booking's account.
Cancellations made less than 24 hours before the start time are considered late cancellations. A late cancellation forfeits 50% of the quoted price; the remaining balance is refunded to the original payment method within 3-5 business days.
No-shows -- bookings that are never cancelled and never checked in -- forfeit the full quoted price. Reservo does not distinguish between a no-show and a same-day cancellation made after the reserved start time has already passed.
Pro members receive one exception per calendar month: a single late cancellation (within the 24-hour window) can be waived on request, refunded in full instead of the standard 50% penalty. Basic members do not have this exception available.
Cancellations are processed through the same channel used to make the original booking. There is no cancellation fee beyond the percentage forfeited under this policy -- Reservo does not charge a separate administrative fee.
"""
MEMBERSHIP_TIERS_TEXT = """# Membership Tiers
Reservo offers two membership tiers: `basic` and `pro`. Both tiers can book any room type (Focus, Studio, Boardroom) at the standard hourly rate quoted by `get_quote`.
`basic` membership has no monthly fee and no discount. Every quote is charged at the room's full hourly rate, with no adjustment applied.
`pro` membership includes a 20% discount on every quote, applied automatically -- the discount does not need to be requested and is never combined with any other promotion. A Focus room quoted at 2500 cents/hour for a basic member is quoted at 2000 cents/hour for a pro member (`2500 * 80 // 100`, always rounded down to the nearest whole cent).
`pro` membership also includes the late-cancellation exception described in the cancellation policy: one late cancellation per calendar month can be waived and refunded in full instead of forfeiting 50% of the quoted price.
Upgrading from `basic` to `pro` (or downgrading back) takes effect on the next booking made after the change -- it does not retroactively adjust bookings already confirmed under the previous tier.
"""
RESOURCES = {
"reservo://policies/cancellation-policy": {
"uri": "reservo://policies/cancellation-policy",
"name": "cancellation-policy",
"description": "Reservo's cancellation window and refund rules",
"mimeType": "text/markdown",
"text": CANCELLATION_POLICY_TEXT,
},
"reservo://policies/membership-tiers": {
"uri": "reservo://policies/membership-tiers",
"name": "membership-tiers",
"description": "Basic vs Pro membership tiers and the Pro discount",
"mimeType": "text/markdown",
"text": MEMBERSHIP_TIERS_TEXT,
},
}
def send(message: dict) -> None:
"""ONE JSON-RPC message per line on stdout. Never a loose print() here."""
sys.stdout.write(json.dumps(message) + "\n")
sys.stdout.flush()
def log(text: str) -> None:
"""Logs ALWAYS to stderr -- stdout is exclusive to 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 handle_resources_list(msg_id) -> dict:
log(f"[server] resources/list -> {len(RESOURCES)} resources")
return {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"resources": [
{
"uri": resource["uri"],
"name": resource["name"],
"description": resource["description"],
"mimeType": resource["mimeType"],
}
for resource in RESOURCES.values()
]
},
}
def handle_resources_read(msg_id, params: dict) -> dict:
uri = params.get("uri")
resource = RESOURCES.get(uri)
if resource is None:
log(f"[server] resources/read <- uri={uri!r} NOT FOUND")
return {
"jsonrpc": "2.0",
"id": msg_id,
"error": {"code": -32002, "message": "Resource not found", "data": {"uri": uri}},
}
log(f"[server] resources/read <- uri={uri}")
return {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"contents": [
{"uri": resource["uri"], "mimeType": resource["mimeType"], "text": resource["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 <- the client confirms it can now operate")
elif method == "resources/list":
send(handle_resources_list(msg_id))
elif method == "resources/read":
send(handle_resources_read(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()
Reference solution: the complete client
# reservo_mcp_client.py
"""Reservo MCP client: handshake (M2) + resources/list + resources/read (M4).
Runs the complete cycle over real stdio (subprocess.Popen + pipes)."""
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)
def send(proc: subprocess.Popen, message: dict) -> None:
line = json.dumps(message)
print(f"[client -> server] {line}")
proc.stdin.write(line + "\n")
proc.stdin.flush()
def recv(proc: subprocess.Popen) -> dict:
line = proc.stdout.readline().strip()
print(f"[server -> client] {line}")
return json.loads(line)
def main() -> None:
proc = subprocess.Popen(
[sys.executable, "reservo_mcp_server.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
# 1) initialize -> notifications/initialized (M2's handshake)
send(proc, {
"jsonrpc": "2.0",
"id": next(request_ids),
"method": "initialize",
"params": {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": CLIENT_INFO,
},
})
init_response = recv(proc)
assert "resources" in init_response["result"]["capabilities"]
send(proc, {"jsonrpc": "2.0", "method": "notifications/initialized"})
# 2) resources/list -- discover what resources the server offers
send(proc, {"jsonrpc": "2.0", "id": next(request_ids), "method": "resources/list"})
list_response = recv(proc)
resources = list_response["result"]["resources"]
print(f"[client] discovered {len(resources)} resources:")
for resource in resources:
print(f" - {resource['uri']} ({resource['mimeType']})")
# 3) resources/read -- read each discovered resource, one by one
for resource in resources:
send(proc, {
"jsonrpc": "2.0",
"id": next(request_ids),
"method": "resources/read",
"params": {"uri": resource["uri"]},
})
read_response = recv(proc)
content = read_response["result"]["contents"][0]
print(f"[client] {content['uri']} -> {len(content['text'])} characters of {content['mimeType']}")
proc.stdin.close()
proc.wait(timeout=5)
print("---- server stderr (logs, never MCP messages) ----")
print(proc.stderr.read().rstrip())
if __name__ == "__main__":
main()
What to expect (running python3.14 reservo_mcp_client.py):
[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": "resources/list"}
[server -> client] {"jsonrpc": "2.0", "id": 2, "result": {"resources": [{"uri": "reservo://policies/cancellation-policy", "name": "cancellation-policy", "description": "Reservo's cancellation window and refund rules", "mimeType": "text/markdown"}, {"uri": "reservo://policies/membership-tiers", "name": "membership-tiers", "description": "Basic vs Pro membership tiers and the Pro discount", "mimeType": "text/markdown"}]}}
[client] discovered 2 resources:
- reservo://policies/cancellation-policy (text/markdown)
- reservo://policies/membership-tiers (text/markdown)
[client -> server] {"jsonrpc": "2.0", "id": 3, "method": "resources/read", "params": {"uri": "reservo://policies/cancellation-policy"}}
[server -> client] {"jsonrpc": "2.0", "id": 3, "result": {"contents": [{"uri": "reservo://policies/cancellation-policy", "mimeType": "text/markdown", "text": "# Cancellation Policy\n\nReservo bookings can be cancelled free of charge up to 24 hours before the reserved start time. Cancellations made within this window are refunded in full, with no penalty applied to the booking's account.\n\nCancellations made less than 24 hours before the start time are considered late cancellations. A late cancellation forfeits 50% of the quoted price; the remaining balance is refunded to the original payment method within 3-5 business days.\n\nNo-shows -- bookings that are never cancelled and never checked in -- forfeit the full quoted price. Reservo does not distinguish between a no-show and a same-day cancellation made after the reserved start time has already passed.\n\nPro members receive one exception per calendar month: a single late cancellation (within the 24-hour window) can be waived on request, refunded in full instead of the standard 50% penalty. Basic members do not have this exception available.\n\nCancellations are processed through the same channel used to make the original booking. There is no cancellation fee beyond the percentage forfeited under this policy -- Reservo does not charge a separate administrative fee.\n"}]}}
[client] reservo://policies/cancellation-policy -> 1170 characters of text/markdown
[client -> server] {"jsonrpc": "2.0", "id": 4, "method": "resources/read", "params": {"uri": "reservo://policies/membership-tiers"}}
[server -> client] {"jsonrpc": "2.0", "id": 4, "result": {"contents": [{"uri": "reservo://policies/membership-tiers", "mimeType": "text/markdown", "text": "# Membership Tiers\n\nReservo offers two membership tiers: `basic` and `pro`. Both tiers can book any room type (Focus, Studio, Boardroom) at the standard hourly rate quoted by `get_quote`.\n\n`basic` membership has no monthly fee and no discount. Every quote is charged at the room's full hourly rate, with no adjustment applied.\n\n`pro` membership includes a 20% discount on every quote, applied automatically -- the discount does not need to be requested and is never combined with any other promotion. A Focus room quoted at 2500 cents/hour for a basic member is quoted at 2000 cents/hour for a pro member (`2500 * 80 // 100`, always rounded down to the nearest whole cent).\n\n`pro` membership also includes the late-cancellation exception described in the cancellation policy: one late cancellation per calendar month can be waived and refunded in full instead of forfeiting 50% of the quoted price.\n\nUpgrading from `basic` to `pro` (or downgrading back) takes effect on the next booking made after the change -- it does not retroactively adjust bookings already confirmed under the previous tier.\n"}]}}
[client] reservo://policies/membership-tiers -> 1097 characters of text/markdown
---- server stderr (logs, never MCP messages) ----
[server] starting up, waiting for messages on stdin...
[server] initialize <- client {'name': 'reservo-mcp-client', 'version': '1.0.0'}, protocolVersion=2025-06-18
[server] notifications/initialized <- the client confirms it can now operate
[server] resources/list -> 2 resources
[server] resources/read <- uri=reservo://policies/cancellation-policy
[server] resources/read <- uri=reservo://policies/membership-tiers
Nine JSON-RPC messages in total (five outgoing requests/notifications from the client, four responses from the server — notifications/initialized generates no response), and the final stderr block confirms the server processed each one in the correct order, with no log ever leaking into stdout in between.
How to verify your own version
If you wrote your own version before looking at the reference solution (recommended), this checklist confirms, with code, that your server is correct — not just "looks fine":
# checklist.py -- run this against YOUR version of reservo_mcp_server.py
import subprocess, sys, json, itertools
ids = itertools.count(1)
proc = subprocess.Popen(
[sys.executable, "reservo_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"}}})
init_response = recv()
send({"jsonrpc": "2.0", "method": "notifications/initialized"})
send({"jsonrpc": "2.0", "id": next(ids), "method": "resources/list"})
list_response = recv()
resources = list_response["result"]["resources"]
send({"jsonrpc": "2.0", "id": next(ids), "method": "resources/read",
"params": {"uri": resources[0]["uri"]}})
read_response = recv()
send({"jsonrpc": "2.0", "id": next(ids), "method": "resources/read",
"params": {"uri": "reservo://policies/does-not-exist"}})
error_response = recv()
checks = {
"capabilities.resources present in initialize": "resources" in init_response["result"]["capabilities"],
"resources/list returns exactly 2 resources": len(resources) == 2,
"both uris start with reservo://policies/": all(r["uri"].startswith("reservo://policies/") for r in resources),
"both have mimeType text/markdown": all(r["mimeType"] == "text/markdown" for r in resources),
"resources/read returns contents with 1 element": len(read_response["result"]["contents"]) == 1,
"the read contents carries the same uri requested": read_response["result"]["contents"][0]["uri"] == resources[0]["uri"],
"nonexistent uri returns error -32002": error_response["error"]["code"] == -32002,
}
for description, passed in checks.items():
print(f"{'OK ' if passed else 'FAIL'} -- {description}")
proc.stdin.close()
proc.wait(timeout=5)
What to expect (running the checklist against the reference solution):
OK -- capabilities.resources present in initialize
OK -- resources/list returns exactly 2 resources
OK -- both uris start with reservo://policies/
OK -- both have mimeType text/markdown
OK -- resources/read returns contents with 1 element
OK -- the read contents carries the same uri requested
OK -- nonexistent uri returns error -32002
If your own version produces any FAIL, you already know exactly which method to check: if the first one fails, check handle_initialize; if the second or third fail, check RESOURCES or handle_resources_list; if the last three fail, check handle_resources_read.
Common mistakes
-
Forgetting to declare
"resources": {}ininitialize'scapabilities. Even if your server implementsresources/list/resources/readcorrectly, ifcapabilitiesdoesn't include theresourceskey, a client that respects Module 2's capability negotiation could, legitimately, never try calling them — theinitializedeclaration is what conceptually enables everything that follows. -
Returning an uncontrolled Python exception when the
uridoesn't exist. A directRESOURCES[uri](with brackets, instead of.get(uri)) raisesKeyErrorif the key isn't there — and since that code runs inside the server's main loop, an uncaught exception can take down the entire process. The reference solution uses.get(uri)and explicitly checksis None, exactly to avoid this case. -
Reordering
RESOURCESexpectingresources/listto always keep the same order across different server runs. Within the same run it is stable (Python preserves a dictionary's insertion order), but it isn't guaranteed by MCP's specification in general — a robust checklist shouldn't depend onresources[0]always specifically beingcancellation-policy, only on it being one of the two expected ones. -
Writing the checklist with "looks like it works"
print()s instead of explicit boolean comparisons. This lesson'schecks = {"description": boolean_condition, ...}pattern is preferable because every check is independent and its result (True/False) is explicit — easier to debug than reading a long transcript looking for something that "looks off."
Exercises
Exercise 1: Count the messages (Easy)
Of this lesson's "What to expect" transcript's nine JSON-RPC messages (counting both the ones from the client and the ones from the server), how many are requests, how many are responses, and how many are notifications? Use Module 2's vocabulary (lesson 02).
See solution
Requests (4): initialize, resources/list, and the two resources/reads — each with an id and expecting a response.
Responses (4): the response to each of the four requests above, each with the same id as its corresponding request.
Notifications (1): notifications/initialized — no id, no response.
Total: 4 + 4 + 1 = 9 messages, matching the "What to expect" count. Notice every request generated exactly one response — none went unanswered, and the transcript's only notification generated none.
Exercise 2: Add a third policy and extend the checklist (Medium)
Add a third resource to the catalog, reservo://policies/support-hours (mimeType: "text/plain", a short text of your own invention), and extend this lesson's checklist.py with a new check: that resources/list now returns exactly 3 resources, not 2.
See solution
On the server:
RESOURCES["reservo://policies/support-hours"] = {
"uri": "reservo://policies/support-hours",
"name": "support-hours",
"description": "Reservo customer support hours",
"mimeType": "text/plain",
"text": "Reservo support is available Monday to Friday, 9am to 6pm (local time).",
}
On the checklist, change the existing check:
checks["resources/list returns exactly 2 resources"] = len(resources) == 2
to:
checks["resources/list returns exactly 3 resources"] = len(resources) == 3
Expected output, with the rest of the checks unchanged (the mimeType text/markdown check for "both" would still pass if adjusted to resources[:2], since the new one isn't markdown):
OK -- capabilities.resources present in initialize
OK -- resources/list returns exactly 3 resources
...
Explanation: this exercise confirms in code what lesson 03 already showed — adding a resource to the catalog is as simple as adding an entry to the RESOURCES dictionary, without touching handle_resources_list or handle_resources_read. The checklist, by depending on len(resources) instead of a fixed number hardcoded in several places, adjusts with a single change.
Exercise 3: A client that only reads resources with a known mimeType (Hard)
Write a function read_if_supported(proc, ids, uri, supported_mime_types) that calls resources/list first, finds the resource with that uri, and only calls resources/read if its mimeType is in supported_mime_types; if it isn't, return None without even trying to read it. If the uri doesn't appear in the catalog, it should also return None. Test it with supported_mime_types = {"text/markdown"} against Reservo's two policies (should be read) and against a made-up uri (shouldn't be read).
See solution
def read_if_supported(proc, ids, uri: str, supported_mime_types: set[str]) -> str | None:
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": next(ids), "method": "resources/list"}) + "\n")
proc.stdin.flush()
resources = json.loads(proc.stdout.readline())["result"]["resources"]
matching = next((r for r in resources if r["uri"] == uri), None)
if matching is None:
print(f"[client] {uri} is not in the catalog -- not attempting to read")
return None
if matching["mimeType"] not in supported_mime_types:
print(f"[client] {uri} has mimeType {matching['mimeType']!r}, not supported -- not reading")
return None
proc.stdin.write(json.dumps({
"jsonrpc": "2.0", "id": next(ids), "method": "resources/read", "params": {"uri": uri},
}) + "\n")
proc.stdin.flush()
response = json.loads(proc.stdout.readline())
return response["result"]["contents"][0]["text"]
# Usage:
ids = itertools.count(1)
# ... proc with the handshake already done ...
text = read_if_supported(proc, ids, "reservo://policies/cancellation-policy", {"text/markdown"})
print("read:", text[:30] if text else None, "...")
nothing = read_if_supported(proc, ids, "reservo://policies/does-not-exist", {"text/markdown"})
print("result with nonexistent uri:", nothing)
Expected output:
read: # Cancellation Policy
Reser ...
[client] reservo://policies/does-not-exist is not in the catalog -- not attempting to read
result with nonexistent uri: None
Explanation: this pattern —filtering by mimeType before calling resources/read, using the information resources/list already brought— avoids making an extra call when you already know in advance you won't be able to process the result (for example, a client that only knows how to render Markdown and wants to silently ignore any image/png resource without trying to read it). It's the same idea of "use the catalog's metadata to decide before paying the cost of the complete operation" that's why resources/list returns mimeType in the first place, instead of forcing you to read every resource to discover its type.
Summary and next step
- You built, with your own hands (or verified the reference solution), an MCP server that extends Module 2's handshake with complete
resources/listandresources/read, serving Reservo's two anchor policies. - A programmatic checklist —seven boolean checks, not just visually reading the transcript— confirms your implementation meets the specification: capabilities, catalog, reading, and the
-32002error on an unknown URI. - This mini-project's server is the literal base Module 5 is going to add
prompts/list/prompts/getonto — the exact same pattern: a new data structure, a new handler function, one more branch inmain()'s dispatch.
That closes Module 4. You have, run and verified with your own hands, MCP's second complete primitive: how a data catalog is discovered (resources/list), how its identity is designed (URIs with your own scheme), how its content is read (resources/read, in text or blob), and when to choose it over a search tool. All of this running over exactly the same stdio transport and the same handshake you built in Module 2 — without changing a single one of its rules.
Next module: Module 5 — Prompts: reusable templates. Reservo's server adds its third and final primitive: plan_booking, a template the user chooses to activate so the assistant checks the cancellation policy and asks for a quote before confirming a booking — prompts/list and prompts/get, that primitive's first messages, run over this same server.
Additional resources
- Model Context Protocol — Specification 2025-06-18: Resources — The complete specification this mini-project implemented end to end:
resources/list,resources/read, and the-32002error. - Model Context Protocol — Specification 2025-06-18: Base Protocol — Module 2's handshake and stdio transport, reused unchanged on this server.
- Python —
subprocess— The basis for the entire client run in this mini-project. - Python —
itertools.count— The deterministic ID generator, consistent with the rest of the guide.