MCP (Model Context Protocol) · lesson 4 of 13
Beginner
4Intermediate
4Advanced
5Also part of MCP (Model Context Protocol)
5MCP Resources and URI Templates
Expose data as MCP resources: static URIs, {name} templates, MIME types and base64 blobs, plus what a client gets for a missing URI, all with real output.
By SkillByExample · Published
Data the Client Reads
Tools are actions a model decides to take. Resources are data a client reads by URI: a config file, a doc page, a database row. A host typically lets the user or the application choose which resources to put in front of the model, instead of leaving it to the model. These examples use the in-memory client from the first lesson.
Static Resources
A decorated function serves one fixed URI. Its return value becomes the content.
# static.py - fixed resources: list them, then read one
import asyncio
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("docs")
@mcp.resource("config://app", mime_type="application/json")
def app_config() -> dict:
"""Runtime settings for the app."""
return {"debug": False, "region": "eu-west-1"}
@mcp.resource("docs://changelog", mime_type="text/markdown")
def changelog() -> str:
return "## 1.2.0\n- Faster search"
async def main():
async with Client(mcp) as client:
for r in (await client.list_resources()).resources:
print(r.uri, r.name, r.mime_type, r.description)
result = await client.read_resource("config://app")
print(result.contents[0].text)
asyncio.run(main())
$ python static.py
config://app app_config application/json Runtime settings for the app.
docs://changelog changelog text/markdown
{
"debug": false,
"region": "eu-west-1"
}
The listing gives each resource’s URI, a name taken from the function, the MIME type you declared and a description from the docstring. changelog has no docstring, so its description is empty. The URI scheme (config://, docs://) is yours to choose. MCP only requires a valid URI, so pick names that tell a host what the data is.
app_config returned a dict, and the SDK serialised it to JSON text. A resource is always delivered as text or as a binary blob, so the client sees a string and parses it itself.
Templates Serve a Family of URIs
Put a {placeholder} in the URI and the handler receives it as an argument.
# template.py - one handler serves a whole family of URIs
import asyncio
from mcp import Client, MCPError
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
mcp = MCPServer("docs")
PAGES = {"setup": "Run pip install mcp.", "faq": "Q: Is it stdio? A: By default."}
@mcp.resource("docs://pages/{name}", mime_type="text/markdown")
def page(name: str) -> str:
"""One documentation page by name."""
if name not in PAGES:
raise ResourceNotFoundError(f"no page named {name!r}")
return PAGES[name]
async def main():
async with Client(mcp) as client:
print("static:", (await client.list_resources()).resources)
print("templates:", [t.uri_template for t in (await client.list_resource_templates()).resource_templates])
print((await client.read_resource("docs://pages/setup")).contents[0].text)
try:
await client.read_resource("docs://pages/billing")
except MCPError as e:
print("MCPError", e.error.code, e.error.message)
asyncio.run(main())
$ python template.py 2>/dev/null
static: []
templates: ['docs://pages/{name}']
Run pip install mcp.
MCPError -32602 no page named 'billing'
The template isn’t in list_resources(). A server can’t list every value {name} might take, so templates have their own listing, and the client fills in the placeholder. Reading docs://pages/setup matched the pattern and called page(name="setup").
The missing page behaves differently from a failing tool. It isn’t a result with an error flag; read_resource raised MCPError with code -32602 and your message. ResourceNotFoundError is the “this doesn’t exist” error. The server also logged the failure to stderr, which the 2>/dev/null hid. The prompts lesson shows how a client can ask which values {name} accepts.
Binary Content
Return bytes and the content becomes a base64 blob instead of text.
# binary.py - bytes travel as base64 blobs
import asyncio
import base64
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("assets")
@mcp.resource("assets://logo.png", mime_type="image/png")
def logo() -> bytes:
return b"\x89PNG\r\n\x1a\n" # the 8-byte PNG signature, standing in for an image
async def main():
async with Client(mcp) as client:
item = (await client.read_resource("assets://logo.png")).contents[0]
print(type(item).__name__, item.mime_type)
print("blob:", item.blob)
print("bytes:", base64.b64decode(item.blob))
asyncio.run(main())
$ python binary.py
BlobResourceContents image/png
blob: iVBORw0KGgo=
bytes: b'\x89PNG\r\n\x1a\n'
JSON can’t carry raw bytes, so the SDK base64-encoded them into a blob field and the client got BlobResourceContents instead of TextResourceContents. Decoding gives back the exact eight bytes. Base64 makes data about a third larger on the wire, so serve big files by reference (a URL, or a URI the host fetches when needed) rather than inlining them in every read.
Practice
- Add a docstring to
changeloginstatic.py. What changes in the listing? - Make a template with two placeholders,
docs://{lang}/pages/{name}, backed by a dict keyed by(lang, name). Readdocs://de/pages/setup. - In your two-placeholder handler, look up a missing key without checking first, so it raises
KeyError. What does the client see, and what’s missing from the message? - Change
app_configto return the string'{"debug": false}'instead of a dict. Does the client’s output change?
Solution 1
The docstring becomes the description, so the line ends in your text instead of a trailing space. A host may show that description to a user choosing what to attach, so write it for a person.
Solution 2
PAGES = {("en", "setup"): "Run pip install mcp.", ("de", "setup"): "pip install mcp ausführen."}
@mcp.resource("docs://{lang}/pages/{name}", mime_type="text/markdown")
def page(lang: str, name: str) -> str:
"""One page in one language."""
return PAGES[(lang, name)]
Reading docs://de/pages/setup prints pip install mcp ausführen.. Each placeholder becomes a keyword argument with the same name.
Solution 3
Reading docs://fr/pages/setup raises MCPError -32603 Error creating resource from template docs://fr/pages/setup. -32603 is “internal error”: the SDK treated the KeyError as a crash, so the message names only the URI and the KeyError text stays in the server log. To report a missing page as -32602 with your own message, raise ResourceNotFoundError, as template.py does.
Solution 4
The output is the same text minus the indentation: {"debug": false}. A str return is sent as-is, while a dict is serialised for you. Either way the client receives text and parses it.