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 resources in its initialize capabilities (it already did since Module 2, alongside tools and prompts — unchanged).
  • Both anchor resources, exact: reservo://policies/cancellation-policy and reservo://policies/membership-tiers, both mimeType: "text/markdown".
  • resources/read with a uri not in the catalog must respond -32002 Resource not found, with data.uri indicating which one failed — not an uncontrolled Python exception, not a silent message.
  • stdout remains the server's exclusive territory for JSON-RPC messages — Module 2's hard rule isn't up for negotiation.
  • IDs via itertools.count(1), never random/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

  1. Forgetting to declare "resources": {} in initialize's capabilities. Even if your server implements resources/list/resources/read correctly, if capabilities doesn't include the resources key, a client that respects Module 2's capability negotiation could, legitimately, never try calling them — the initialize declaration is what conceptually enables everything that follows.

  2. Returning an uncontrolled Python exception when the uri doesn't exist. A direct RESOURCES[uri] (with brackets, instead of .get(uri)) raises KeyError if 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 checks is None, exactly to avoid this case.

  3. Reordering RESOURCES expecting resources/list to 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 on resources[0] always specifically being cancellation-policy, only on it being one of the two expected ones.

  4. Writing the checklist with "looks like it works" print()s instead of explicit boolean comparisons. This lesson's checks = {"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/list and resources/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 -32002 error on an unknown URI.
  • This mini-project's server is the literal base Module 5 is going to add prompts/list/prompts/get onto — the exact same pattern: a new data structure, a new handler function, one more branch in main()'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

  1. Model Context Protocol — Specification 2025-06-18: Resources — The complete specification this mini-project implemented end to end: resources/list, resources/read, and the -32002 error.
  2. Model Context Protocol — Specification 2025-06-18: Base Protocol — Module 2's handshake and stdio transport, reused unchanged on this server.
  3. Python — subprocess — The basis for the entire client run in this mini-project.
  4. Python — itertools.count — The deterministic ID generator, consistent with the rest of the guide.