Resources
Not a read-only tool
A resource is data the application loads and hands to the model, addressed by URI. The distinction from Lesson 3 matters here in practice: a tool is something the model decides to call, a resource is something the host decides to provide.
@mcp.resource("rooms://catalog", mime_type="application/json")
def room_catalog() -> dict[str, dict]:
"""Every bookable room on the floor, with seats and location."""
return ROOMSReading it:
result = await client.read_resource("rooms://catalog")
print(result.contents[0].text){
"atrium": {"name": "The Atrium", "seats": 24, "floor": 0, "note": "glass, noisy"},
"studio-b": {"name": "Studio B", "seats": 12, "floor": 2, "note": "biggest room upstairs"},
"nook": {"name": "The Nook", "seats": 4, "floor": 2, "note": "no window"}
}The URI scheme is yours to choose. rooms://, policy://, file:// — the protocol does not care, and the convention is to pick something that says what kind of thing is being addressed.
The menu and the order
A menu sits on the table whether or not anyone uses it. Reading it commits you to nothing, changes nothing, and it is there so you can decide. An order is an action with consequences, and somebody has to bring it.
Resources are the menu; tools are the order. For a beginner that settles the "which one is this?" question faster than any technical definition. For an engineer it is the safe/unsafe method distinction — a resource read should be as consequence-free as a GET, and if yours is not, you have built a tool and given it a URI.
Static and templated
A static resource has a fixed URI and appears in resources/list. A templated one has placeholders and appears in resources/templates/list instead:
@mcp.resource("rooms://{room_id}/day/{day}", mime_type="application/json")
def room_day(room_id: str, day: str) -> dict:
"""One room's bookings for one day."""
return {"room_id": room_id, "day": day, "opening": OPENING, "closing": CLOSING}The client sees them separately:
resources: ['rooms://catalog', 'policy://cancellation']
templates: ['rooms://{room_id}/day/{day}']That split is deliberate. A static resource can be enumerated and offered in a picker. A template cannot — there are infinitely many rooms://.../day/... URIs — so a client offers it as something to fill in.
Placeholders must match parameter names, and this is checked when your module imports rather than when the resource is read. Rename day to date in the signature but not the URI and you get an error at startup — which is the right time to find out, and worth knowing so the error is not mysterious.
Return types and MIME
The return type determines what is transmitted:
@mcp.resource("policy://cancellation", mime_type="text/markdown")
def cancellation_policy() -> str:
"""The floor's cancellation policy, as members are shown it."""
return (
"## Cancellation\n\n"
"Cancel more than 2 hours ahead and the slot is released with no charge.\n"
"Inside 2 hours the slot is held and counts against your monthly allowance.\n"
)A str is sent as-is. A dict or any JSON-serialisable object is serialised to JSON text. bytes are base64-encoded as a blob. mime_type defaults to text/plain and is worth setting, because it is how a client decides whether to render, parse or download.
The test, and the honest complication
The test from Lesson 3, restated for the case you will actually hit: who should decide this happens? Application decides, resource. Model decides, tool.
Now the complication, because you will meet it within a day of shipping.
Many hosts today surface tools far better than resources. Tool support is universal; resource support varies, and in some clients a resource is something the user must go and attach manually, which means in practice it never gets attached. You can design the architecturally correct thing and have nobody reach it.
The pragmatic answer, when the data genuinely matters, is to ship both — the resource for hosts that use resources, and a thin read-only tool over the same function for hosts that do not:
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True))
def get_room_catalog() -> dict[str, dict]:
"""Every bookable room, with seats and location. Read this before suggesting a room."""
return room_catalog()That is duplication, and it is the right call when the alternative is a capability nobody can reach. Check what your target hosts actually do before deciding.
If you have read an older tutorial, you may have seen resources described as the primary way to give a model context. That was the design intent and remains architecturally true; adoption simply ran ahead in one direction, so treat resource support as something to verify per host rather than assume.
What to take into the next lesson
A resource is application-controlled data addressed by URI: static ones are enumerable, templated ones are filled in, placeholders are validated at import, and the return type plus mime_type decide what is transmitted. Verify your hosts actually surface them, and ship a thin tool alongside when they do not. Next: the primitive the user picks.