Resources are the read side of MCP: addressable, cacheable data a client fetches by URI, separate from the tools that take action. Serving them well means more than returning a string. A resource has a MIME type, metadata a client shows in a picker, a choice between a fixed URI and a parameterized template, a text-or-binary body, and, for data that changes, a way to push updates instead of making clients poll.
This tutorial builds a small dashboard server with FastMCP
that covers the full range: a static resource with metadata, a template, a binary
resource, and a subscribable resource that sends
notifications/resources/updated when its value changes. The stack is Mac-native:
uv, make, and pytest.
Resources vs tools
A tool is a verb the model calls to make something happen. A resource is a noun a client reads. The client decides when to fetch a resource and can cache it by URI, so resources are the right home for reference data, files, and current state — the things a model should be able to look at without a side effect. The article on tool design covers the other half; this one is all resources.
What you will build
server.py: four resources:dashboard://info(static, with metadata),metric://{name}(template),dashboard://badge(binary), anddashboard://hits(subscribable), plus arecord_hittool and low-level subscribe handlers.client.py: an in-memory client that lists and reads each kind, then subscribes and watches for an update push.- A
pytestsuite covering metadata, templates, blobs, and the subscription flow. - A
makewrapper with a help screen.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - Familiarity with FastMCP resources and the in-memory
Client(see Build an MCP Server with FastMCP and Build a Dynamic MCP Server).
Step 1: Add project hygiene
Create the file
mkdir -p serve-mcp-resources-macos
cd serve-mcp-resources-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
*.log
# OS / editor noise
.DS_Store
Detailed breakdown
- Standard Python ignores plus
.DS_Store, created first so the virtualenv and caches never get committed.
Step 2: Initialize the project with uv
Create the files
uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "A dashboard MCP server: static, template, binary, and subscribable resources"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.4",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
fastmcpis the only runtime dependency. TheResourceUpdatedNotificationtype andAnyUrlcome from themcpandpydanticpackages it installs.
Step 3: The server and its four resources
The server defines one resource of each kind. Read the URIs first: a scheme plus a
single word (dashboard://info) stays a clean static URI, while a {name}
placeholder (metric://{name}) makes a template.
Create the file
touch server.py
Add the code: server.py
"""A dashboard MCP server that shows how to serve resources well.
Resources are the read side of MCP: addressable, cacheable data a client fetches
by URI. This server covers the range:
- a **static** resource with full metadata (name, description, MIME, tags);
- a **template** resource, parameterized by a `{name}` placeholder;
- a **binary** resource that returns `bytes` (served as a base64 blob);
- a **subscribable** resource: a client can subscribe and get a
`notifications/resources/updated` push whenever the value changes.
Subscriptions need low-level wiring (see the subscribe handlers below); the rest
is plain FastMCP.
Run over stdio with `python server.py`, or drive it with client.py.
"""
from fastmcp import Context, FastMCP
from fastmcp.exceptions import ToolError
from mcp.types import ResourceUpdatedNotification
from pydantic import AnyUrl
mcp = FastMCP("dashboard")
METRICS = {"cpu": 42, "memory": 65, "disk": 20}
# Mutable state behind the subscribable resource, plus the set of URIs that
# clients have subscribed to in this session.
_state = {"hits": 0}
_subscribers: set[str] = set()
# PNG magic number — a fixed, tiny stand-in for a binary asset.
BADGE_BYTES = b"\x89PNG\r\n\x1a\n"
def reset() -> None:
"""Reset mutable state. Call before each test."""
_state["hits"] = 0
_subscribers.clear()
@mcp.resource(
"dashboard://info",
name="Dashboard info",
description="Human-readable summary of the dashboard server.",
mime_type="text/markdown",
tags={"docs"},
)
def info() -> str:
"""A static resource: fixed content plus rich metadata."""
return "# Dashboard\n\nExposes CPU, memory, and disk metrics."
@mcp.resource("metric://{name}", mime_type="application/json")
def metric(name: str) -> dict:
"""A template resource: `metric://cpu` reads one metric by name."""
if name not in METRICS:
raise ToolError(f"Unknown metric {name!r}. Known: {', '.join(METRICS)}.")
return {"metric": name, "value": METRICS[name]}
@mcp.resource("dashboard://badge", mime_type="image/png")
def badge() -> bytes:
"""A binary resource: returning bytes serves a base64 blob, not text."""
return BADGE_BYTES
@mcp.resource("dashboard://hits", mime_type="application/json")
def hits() -> dict:
"""A subscribable resource: its value changes when a hit is recorded."""
return {"hits": _state["hits"]}
@mcp.tool
async def record_hit(ctx: Context) -> int:
"""Increment the hit counter and notify subscribers of dashboard://hits."""
_state["hits"] += 1
if "dashboard://hits" in _subscribers:
await ctx.send_notification(
ResourceUpdatedNotification(params={"uri": "dashboard://hits"})
)
return _state["hits"]
# --- Low-level subscription handlers ----------------------------------------
# FastMCP 3.4.4 has no high-level API for resource subscriptions, so register
# the handlers on the underlying server. Each receives the subscribed URI.
@mcp._mcp_server.subscribe_resource()
async def _subscribe(uri: AnyUrl) -> None:
_subscribers.add(str(uri))
@mcp._mcp_server.unsubscribe_resource()
async def _unsubscribe(uri: AnyUrl) -> None:
_subscribers.discard(str(uri))
if __name__ == "__main__":
mcp.run()
Detailed breakdown
infois static with metadata. The decorator setsname,description,mime_type, andtags. A client shows the name in its resource picker and uses the MIME type to render the body;text/markdowntells it this is formatted text, not plain.metricis a template. The{name}placeholder makesmetric://{name}appear in the template list, and anymetric://cpuread fills inname="cpu". An unknown metric raisesToolError, which the client receives as a read error.badgereturnsbytes. FastMCP serves abytesreturn as a blob: the content block carries a base64blobfield instead oftext. Setmime_typeso the client knows how to decode it (image/pnghere).hitsis the subscribable resource. It reads mutable state. On its own it is an ordinary resource; what makes it live is the subscribe handling below plus the notification inrecord_hit.record_hitsends the update. After changing the state, it sendsResourceUpdatedNotificationfordashboard://hits, but only if a client has subscribed. Changing the data and notifying are separate steps, exactly as withlist_changed.- The subscribe handlers are low-level. FastMCP 3.4.4 has no high-level
decorator for
resources/subscribe, so_subscribeand_unsubscribeare registered onmcp._mcp_server. Each records or drops the URI sorecord_hitknows who to notify.
Step 4: Read and subscribe from a client
Create the file
touch client.py
Add the code: client.py
"""Drive the dashboard server in-memory.
Lists resources and templates with their metadata, reads a static, a template,
and a binary resource, then subscribes to the live resource and watches for the
`resources/updated` push when a hit is recorded.
"""
import asyncio
import base64
from fastmcp import Client
from fastmcp.client.messages import MessageHandler
from pydantic import AnyUrl
import server
from server import mcp
class Watcher(MessageHandler):
async def on_resource_updated(self, message) -> None:
print(f" << resources/updated: {message.params.uri}")
async def main() -> None:
server.reset()
async with Client(mcp, message_handler=Watcher()) as client:
print("Static resources:")
for r in await client.list_resources():
print(f" {r.uri} [{r.mimeType}] {r.name}")
print("Templates:")
for t in await client.list_resource_templates():
print(f" {t.uriTemplate} [{t.mimeType}]")
# Static (text) and template resources.
info = await client.read_resource("dashboard://info")
print("\ninfo:", repr(info[0].text))
cpu = await client.read_resource("metric://cpu")
print("metric://cpu:", cpu[0].text)
# Binary resource: the content carries a base64 `blob`, not text.
badge = await client.read_resource("dashboard://badge")
raw = base64.b64decode(badge[0].blob)
print(f"badge: {len(raw)} bytes, mime={badge[0].mimeType}")
# Subscribe, then trigger two updates.
await client.session.subscribe_resource(AnyUrl("dashboard://hits"))
print("\nsubscribed to dashboard://hits")
await client.call_tool("record_hit", {})
await asyncio.sleep(0.05)
await client.call_tool("record_hit", {})
await asyncio.sleep(0.05)
latest = await client.read_resource("dashboard://hits")
print("re-read hits:", latest[0].text)
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
list_resourcesvslist_resource_templatesare separate calls, so the static resources and themetric://{name}template come back in different lists.- Reading a text vs binary resource gives different content:
info[0].textholds the markdown, whilebadge[0].blobholds base64 thatbase64.b64decodeturns back into the original bytes. client.session.subscribe_resource(...)reaches through to the low-level session because the FastMCPClienthas no high-levelsubscribemethod. After subscribing, eachrecord_hittriggers theWatcher.on_resource_updatedcallback.
Run it:
uv run python client.py
Expected output:
Static resources:
dashboard://info [text/markdown] Dashboard info
dashboard://badge [image/png] badge
dashboard://hits [application/json] hits
Templates:
metric://{name} [application/json]
info: '# Dashboard\n\nExposes CPU, memory, and disk metrics.'
metric://cpu: {"metric": "cpu", "value": 42}
badge: 8 bytes, mime=image/png
subscribed to dashboard://hits
<< resources/updated: dashboard://hits
<< resources/updated: dashboard://hits
re-read hits: {"hits": 2}
The two << resources/updated lines are the server pushing a notification each
time the hit count changed, and the final read shows the new value.
Step 5: Test the resources
Create the files
mkdir -p tests
touch tests/__init__.py
touch pytest.ini
touch tests/test_server.py
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
Detailed breakdown
asyncio_mode = autoruns the async tests without a marker.tests/__init__.pymakestests/a package so the project root lands onsys.pathandfrom server import mcpresolves.
Add the code: tests/test_server.py
"""Tests for the dashboard resource server.
Covers static-resource metadata, template listing and reads, binary/blob
content, the subscription push (and its absence without a subscribe), and the
`subscribe=False` capability caveat.
"""
import base64
import pytest
from fastmcp import Client
from fastmcp.client.messages import MessageHandler
from mcp.shared.exceptions import McpError
from pydantic import AnyUrl
import server
from server import BADGE_BYTES, mcp
@pytest.fixture(autouse=True)
def fresh():
server.reset()
class Watcher(MessageHandler):
def __init__(self):
self.updated: list[str] = []
async def on_resource_updated(self, message) -> None:
self.updated.append(str(message.params.uri))
async def test_static_resource_has_metadata():
async with Client(mcp) as client:
resources = {str(r.uri): r for r in await client.list_resources()}
info = resources["dashboard://info"]
assert info.mimeType == "text/markdown"
assert info.name == "Dashboard info"
async def test_template_is_listed_separately():
async with Client(mcp) as client:
templates = {t.uriTemplate for t in await client.list_resource_templates()}
assert "metric://{name}" in templates
static = {str(r.uri) for r in await client.list_resources()}
assert "metric://{name}" not in static # templates are not static resources
async def test_template_read_and_unknown():
async with Client(mcp) as client:
cpu = await client.read_resource("metric://cpu")
assert cpu[0].text == '{"metric": "cpu", "value": 42}'
with pytest.raises(McpError):
await client.read_resource("metric://nope")
async def test_binary_resource_is_a_blob():
async with Client(mcp) as client:
content = (await client.read_resource("dashboard://badge"))[0]
assert content.mimeType == "image/png"
assert base64.b64decode(content.blob) == BADGE_BYTES
assert getattr(content, "text", None) is None # not a text resource
async def test_subscription_pushes_updates():
watcher = Watcher()
async with Client(mcp, message_handler=watcher) as client:
await client.session.subscribe_resource(AnyUrl("dashboard://hits"))
await client.call_tool("record_hit", {})
await client.call_tool("record_hit", {})
latest = await client.read_resource("dashboard://hits")
assert latest[0].text == '{"hits": 2}'
assert watcher.updated == ["dashboard://hits", "dashboard://hits"]
async def test_no_push_without_subscribe():
watcher = Watcher()
async with Client(mcp, message_handler=watcher) as client:
await client.call_tool("record_hit", {}) # never subscribed
assert watcher.updated == []
async def test_subscribe_capability_is_not_advertised():
"""FastMCP 3.4.4 hardcodes resources.subscribe=False even though the
subscribe handler works when called directly."""
async with Client(mcp) as client:
assert client.initialize_result.capabilities.resources.subscribe is False
Detailed breakdown
test_binary_resource_is_a_blobdecodes the base64blobback to the exact bytes and confirms there is notextfield, which is how a client tells a binary resource from a text one.test_subscription_pushes_updatesandtest_no_push_without_subscribeare a pair: an update fires only for a subscribed URI, so a client that did not subscribe hears nothing.test_subscribe_capability_is_not_advertiseddocuments the caveat in code: the handler works, but FastMCP reportssubscribe=False, so a strict client that gates on the capability would not subscribe.
Step 6: Wrap it in a Makefile
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help install demo serve test clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
demo: ## List and read resources, then watch a subscription update
uv run python client.py
serve: ## Run the server over stdio (for a real MCP client)
uv run python server.py
test: ## Run the test suite
uv run pytest -q
clean: ## Remove caches
rm -rf .pytest_cache __pycache__ tests/__pycache__
Detailed breakdown
.DEFAULT_GOAL := helpmakes a baremakeprint the help screen. Recipe bodies must be tab-indented ormakeerrors.
Step 7: Run everything
make # prints the help screen
make demo # lists, reads, and subscribes (output shown in Step 4)
make test # runs the suite
The suite passes:
....... [100%]
7 passed in 0.33s
Notes on serving resources
- Static vs template. Use a static resource for one fixed thing
(
dashboard://info) and a template when the URI selects among many (metric://{name}). Templates keep you from registering one resource per row. - Set the MIME type. It is how a client decides to render markdown, parse JSON, or decode an image. Leaving it off makes the client guess.
- Text vs binary. Return a
str(or a dict/model that serializes to text) for text; returnbytesfor binary, and the content arrives as a base64 blob. Do not base64-encode by hand — return the raw bytes and let FastMCP encode them. list_changedvsupdated. Two different notifications:list_changedsays “the set of resources changed” (one appeared or disappeared);resources/updatedsays “this specific resource’s content changed.” Subscriptions use the second, and a client subscribes per URI.- The subscription caveat. FastMCP 3.4.4 handles subscribe requests when you
register the low-level handlers, but it advertises
resources.subscribe=falseand theClienthas no high-levelsubscribe. If you need subscriptions today, wire the handlers as shown and drive them throughclient.session; a client that strictly honors the advertised capability will not subscribe until FastMCP surfaces it.
Troubleshooting
- The binary read has no
text. That is correct: abytesreturn becomes a blob. Readcontent.blobandbase64.b64decodeit; do not expectcontent.text. - A client never gets an update. Either no client subscribed (check the
_subscribersset), or the notification was not sent (record_hitonly notifies after changing state). Both steps are required. AttributeError: 'Client' object has no attribute 'subscribe_resource'. Subscribe through the session:client.session.subscribe_resource(AnyUrl(uri)).- A template read 404s. Templates appear in
list_resource_templates, notlist_resources; read a concrete URI (metric://cpu), and make sure the handler covers the value. - A URI grows a trailing slash. A
scheme://host.with.dotsURI gets normalized with a trailing slash. Keep resource URIs to a scheme plus simple path segments (dashboard://info) to avoid surprises. ModuleNotFoundError: No module named 'server'under pytest.tests/needs an__init__.pyso pytest puts the project root onsys.path.
Recap
The dashboard exposed one resource of each shape: a static resource carrying
metadata and a MIME type, a template that reads any metric by name, a binary
resource served as a base64 blob, and a subscribable resource that pushed
resources/updated to a subscribed client instead of making it poll. The one
rough edge, that resource subscriptions are not yet first-class in FastMCP, is
real, and the low-level handlers plus the subscribe=False caveat show exactly
where it stands.
Next improvements:
- Add resource annotations (audience, priority) so a client can rank what to show.
- Back the metrics with a real source and push
updatedwhen any value moves. - Serve a real image or file as the binary resource and render it in a client.