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), and dashboard://hits (subscribable), plus a record_hit tool 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 pytest suite covering metadata, templates, blobs, and the subscription flow.
  • A make wrapper with a help screen.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • 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

  • fastmcp is the only runtime dependency. The ResourceUpdatedNotification type and AnyUrl come from the mcp and pydantic packages 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

  • info is static with metadata. The decorator sets name, description, mime_type, and tags. A client shows the name in its resource picker and uses the MIME type to render the body; text/markdown tells it this is formatted text, not plain.
  • metric is a template. The {name} placeholder makes metric://{name} appear in the template list, and any metric://cpu read fills in name="cpu". An unknown metric raises ToolError, which the client receives as a read error.
  • badge returns bytes. FastMCP serves a bytes return as a blob: the content block carries a base64 blob field instead of text. Set mime_type so the client knows how to decode it (image/png here).
  • hits is 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 in record_hit.
  • record_hit sends the update. After changing the state, it sends ResourceUpdatedNotification for dashboard://hits, but only if a client has subscribed. Changing the data and notifying are separate steps, exactly as with list_changed.
  • The subscribe handlers are low-level. FastMCP 3.4.4 has no high-level decorator for resources/subscribe, so _subscribe and _unsubscribe are registered on mcp._mcp_server. Each records or drops the URI so record_hit knows 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_resources vs list_resource_templates are separate calls, so the static resources and the metric://{name} template come back in different lists.
  • Reading a text vs binary resource gives different content: info[0].text holds the markdown, while badge[0].blob holds base64 that base64.b64decode turns back into the original bytes.
  • client.session.subscribe_resource(...) reaches through to the low-level session because the FastMCP Client has no high-level subscribe method. After subscribing, each record_hit triggers the Watcher.on_resource_updated callback.

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 = auto runs the async tests without a marker.
  • tests/__init__.py makes tests/ a package so the project root lands on sys.path and from server import mcp resolves.

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_blob decodes the base64 blob back to the exact bytes and confirms there is no text field, which is how a client tells a binary resource from a text one.
  • test_subscription_pushes_updates and test_no_push_without_subscribe are a pair: an update fires only for a subscribed URI, so a client that did not subscribe hears nothing.
  • test_subscribe_capability_is_not_advertised documents the caveat in code: the handler works, but FastMCP reports subscribe=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 := help makes a bare make print the help screen. Recipe bodies must be tab-indented or make errors.

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; return bytes for 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_changed vs updated. Two different notifications: list_changed says “the set of resources changed” (one appeared or disappeared); resources/updated says “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=false and the Client has no high-level subscribe. If you need subscriptions today, wire the handlers as shown and drive them through client.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: a bytes return becomes a blob. Read content.blob and base64.b64decode it; do not expect content.text.
  • A client never gets an update. Either no client subscribed (check the _subscribers set), or the notification was not sent (record_hit only 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, not list_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.dots URI 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__.py so pytest puts the project root on sys.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 updated when any value moves.
  • Serve a real image or file as the binary resource and render it in a client.