Most MCP servers are static: a fixed set of tools and resources, decided at startup. Two features let a server change shape at runtime. Resource templates serve a whole family of resources from one parameterized URI, so book://1, book://2, and book://999 all resolve without registering a resource per book. list_changed notifications let the server tell a connected client that its tools, resources, or prompts have changed, so the client re-fetches instead of holding a stale list.

This tutorial builds a small library server with FastMCP that uses both: resource templates for reading books by id and by genre, and a runtime toggle that registers an admin delete_book tool and fires a tools/list_changed notification the client reacts to. The stack is Mac-native: uv, make, and pytest.

How dynamic capabilities work

A client caches what a server exposes. It calls tools/list once after connecting and reuses the result. If the server later adds or removes a tool, the client has no way to know unless the server sends a notification:

  • notifications/tools/list_changed
  • notifications/resources/list_changed
  • notifications/prompts/list_changed

Each one is a nudge that means “re-fetch that list.” The server sends it; a FastMCP client receives it through a MessageHandler callback and can re-list.

Resource templates are the other half. A resource whose URI contains a {placeholder} is not one resource but a pattern. The client sees it in a separate resources/templates/list call and fills in the placeholder to read a concrete URI.

What you will build

  • catalog.py: an in-memory book catalog, seeded deterministically.
  • server.py: resource templates (book://{book_id}, books://genre/{genre}), a static resource, a base search_books tool, and enable_admin / disable_admin tools that register and remove delete_book at runtime with a notification each way.
  • client.py: an in-memory client that reads through the templates and prints a line whenever the server reports its tool list changed.
  • A pytest suite covering template listing/reading and the notification 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 tools, resources, and the in-memory Client (see Build an MCP Server with FastMCP).

Step 1: Add project hygiene

Create the file

mkdir -p dynamic-mcp-server-notifications-macos
cd dynamic-mcp-server-notifications-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 dynamic MCP server: resource templates and list_changed notifications"
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. It provides the Context object used to send notifications and the Tool.from_function helper used to build a tool at runtime.

Step 3: A catalog, kept separate from the server

The catalog is the data the templates read and the admin tool mutates. Keeping it in its own module with a reset() makes the demo and every test deterministic.

Create the file

touch catalog.py

Add the code: catalog.py

"""In-memory book catalog, seeded deterministically.

Kept separate from the server so the resource templates and tools stay thin.
Nothing here imports MCP.
"""

from pydantic import BaseModel


class Book(BaseModel):
    id: int
    title: str
    author: str
    genre: str


SEED = [
    (1, "The Lighthouse", "A. Marlow", "fiction"),
    (2, "Deep Learning", "I. Gradient", "tech"),
    (3, "The Pragmatic Coder", "D. Hunt", "tech"),
]

_BOOKS: dict[int, Book] = {}


def reset() -> None:
    """Clear and re-seed the catalog. Call before each test."""
    global _BOOKS
    _BOOKS = {b.id: b for b in (Book(id=i, title=t, author=a, genre=g) for i, t, a, g in SEED)}


def get(book_id: int) -> Book | None:
    return _BOOKS.get(book_id)


def by_genre(genre: str) -> list[Book]:
    g = genre.lower()
    return [b for b in _BOOKS.values() if b.genre == g]


def all_books() -> list[Book]:
    return list(_BOOKS.values())


def delete(book_id: int) -> bool:
    return _BOOKS.pop(book_id, None) is not None


reset()  # seed on import

Detailed breakdown

  • Book is a Pydantic model; the resources serialize it with model_dump().
  • reset() restores the three seed books, so a test that deletes book 1 does not affect the next test.
  • by_genre lowercases the query, so books://genre/Tech and books://genre/tech behave the same.

Step 4: The server, templates, and runtime tools

The server defines three resources (two templates and one static), a base tool, and the two admin toggles. The admin toggles are the dynamic part: they call mcp.add_tool / mcp.remove_tool while the server is running, then send a notification so the client knows to re-list.

Create the file

touch server.py

Add the code: server.py

"""A dynamic library MCP server: resource templates + list_changed notifications.

Two features that make a server more than a static list of tools:

1. **Resource templates.** A resource URI with a `{placeholder}` becomes a
   parameterized resource. `book://{book_id}` serves any book by id without
   registering one resource per book.

2. **Runtime capability changes.** `enable_admin` registers a `delete_book`
   tool while the server is running and sends a `tools/list_changed`
   notification so the connected client re-fetches its tool list.
   `disable_admin` removes it again.

Run over stdio with `python server.py`, or drive it with client.py.
"""

from fastmcp import Context, FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.tools.tool import Tool

# Import the notification type directly. The server object below is named `mcp`,
# which would shadow the `mcp` package if we wrote `import mcp` and then
# `mcp.types.ToolListChangedNotification`.
from mcp.types import ToolListChangedNotification

import catalog
from catalog import Book

mcp = FastMCP("library")

# Tracks whether the admin tool is currently registered, so enable/disable are
# idempotent and only notify on a real change.
_admin_on = False


# --- Resource templates -----------------------------------------------------


@mcp.resource("book://{book_id}")
def book_resource(book_id: str) -> dict:
    """One book by id. `book://2` resolves `book_id="2"`."""
    book = catalog.get(int(book_id))
    if book is None:
        raise ToolError(f"No book with id {book_id}.")
    return book.model_dump()


@mcp.resource("books://genre/{genre}")
def genre_resource(genre: str) -> dict:
    """Every book in a genre. `books://genre/tech` resolves `genre="tech"`.

    Returns a single dict (not a bare list): a resource that returns a list is
    read as one content item per element, which is rarely what you want.
    """
    return {"genre": genre, "books": [b.model_dump() for b in catalog.by_genre(genre)]}


@mcp.resource("books://all")
def all_books_resource() -> dict:
    """A static (non-parameterized) resource: the whole catalog, summarized."""
    return {"count": len(catalog.all_books()),
            "titles": [b.title for b in catalog.all_books()]}


# --- Base tools -------------------------------------------------------------


@mcp.tool
def search_books(query: str) -> list[Book]:
    """Find books whose title contains `query` (case-insensitive)."""
    q = query.lower()
    return [b for b in catalog.all_books() if q in b.title.lower()]


# --- The dynamically registered tool ----------------------------------------


def delete_book(book_id: int) -> dict:
    """Delete a book by id (admin only). Registered at runtime."""
    existed = catalog.delete(book_id)
    return {"book_id": book_id, "deleted": existed}


@mcp.tool
async def enable_admin(ctx: Context) -> str:
    """Register the admin `delete_book` tool and notify the client."""
    global _admin_on
    if not _admin_on:
        mcp.add_tool(Tool.from_function(delete_book, name="delete_book"))
        _admin_on = True
        await ctx.send_notification(ToolListChangedNotification())
    return "admin tools enabled"


@mcp.tool
async def disable_admin(ctx: Context) -> str:
    """Remove the admin `delete_book` tool and notify the client."""
    global _admin_on
    if _admin_on:
        mcp.remove_tool("delete_book")
        _admin_on = False
        await ctx.send_notification(ToolListChangedNotification())
    return "admin tools disabled"


if __name__ == "__main__":
    mcp.run()

Detailed breakdown

  • The import comment is not a footnote. Naming the server mcp is the convention, but it shadows the mcp package. Writing import mcp and then mcp.types.ToolListChangedNotification would resolve mcp to the server and raise AttributeError: 'FastMCP' object has no attribute 'types'. Import the notification class by name to avoid the collision.
  • book_resource is a template: the {book_id} in the URI becomes the function’s str argument (URI parts are strings, so it casts with int()). One function serves every id. A missing id raises ToolError, which the client receives as a resource-read error.
  • genre_resource returns a single dict, not a list. A resource that returns a bare list is read as one content item per element, which fails with a ResourceContent error; wrapping the list in a dict returns one JSON payload.
  • enable_admin / disable_admin take a Context parameter. FastMCP injects it, and ctx.send_notification(...) pushes the message to the current session. The _admin_on flag makes the toggles idempotent: calling enable_admin twice registers the tool once and notifies once.
  • delete_book is a plain function, not decorated with @mcp.tool. It is wrapped with Tool.from_function and registered only when admin mode turns on. mcp.add_tool and mcp.remove_tool change the catalog but do not notify on their own — the explicit send_notification call is what the client hears.

Step 5: A client that reacts to changes

Create the file

touch client.py

Add the code: client.py

"""Drive the dynamic library server in-memory.

Part 1 reads parameterized resources through their templates. Part 2 reacts to
runtime capability changes: a MessageHandler prints a line every time the server
sends `tools/list_changed`, and the client re-lists its tools to pick up the new
one.
"""

import asyncio

from fastmcp import Client
from fastmcp.client.messages import MessageHandler

import catalog
from server import mcp


class Watcher(MessageHandler):
    """Reacts to server-initiated list_changed notifications."""

    async def on_tool_list_changed(self, message) -> None:
        print("  << notification: tools/list_changed")


async def main() -> None:
    catalog.reset()
    async with Client(mcp, message_handler=Watcher()) as client:
        # Part 1: resource templates.
        print("Static resources:", [str(r.uri) for r in await client.list_resources()])
        print("Templates:       ", [t.uriTemplate for t in await client.list_resource_templates()])

        one = await client.read_resource("book://2")
        print("read book://2       ->", one[0].text)
        tech = await client.read_resource("books://genre/tech")
        print("read books://genre/tech ->", tech[0].text)

        # Part 2: runtime capability change.
        print("\ntools before:", [t.name for t in await client.list_tools()])
        await client.call_tool("enable_admin", {})
        await asyncio.sleep(0.05)  # let the notification arrive
        print("tools after enable:", [t.name for t in await client.list_tools()])

        deleted = await client.call_tool("delete_book", {"book_id": 1})
        print("delete_book(1) ->", deleted.structured_content)

        await client.call_tool("disable_admin", {})
        await asyncio.sleep(0.05)
        print("tools after disable:", [t.name for t in await client.list_tools()])


if __name__ == "__main__":
    asyncio.run(main())

Detailed breakdown

  • Watcher subclasses MessageHandler and overrides on_tool_list_changed. That callback fires whenever the server sends the notification; the base class also has on_resource_list_changed and on_prompt_list_changed.
  • list_resources vs list_resource_templates are separate calls. Templates (with {...}) never appear in list_resources, which is why a client that only lists resources misses them.
  • The asyncio.sleep(0.05) gives the notification time to arrive before the next list_tools. Notifications are one-way and asynchronous; the demo re-lists after a beat to show the effect.

Run it:

uv run python client.py

Expected output:

Static resources: ['books://all']
Templates:        ['book://{book_id}', 'books://genre/{genre}']
read book://2       -> {"id": 2, "title": "Deep Learning", "author": "I. Gradient", "genre": "tech"}
read books://genre/tech -> {"genre": "tech", "books": [{"id": 2, "title": "Deep Learning", "author": "I. Gradient", "genre": "tech"}, {"id": 3, "title": "The Pragmatic Coder", "author": "D. Hunt", "genre": "tech"}]}

tools before: ['search_books', 'enable_admin', 'disable_admin']
  << notification: tools/list_changed
tools after enable: ['search_books', 'enable_admin', 'disable_admin', 'delete_book']
delete_book(1) -> {'book_id': 1, 'deleted': True}
  << notification: tools/list_changed
tools after disable: ['search_books', 'enable_admin', 'disable_admin']

The two << notification lines are the server telling the client its tool list changed — once when delete_book appears, once when it is removed.

Step 6: Test the templates and the notifications

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 dynamic library server.

Covers the two features: parameterized resource templates (listing, reading, and
a not-found error) and runtime capability changes (a tool appearing and
disappearing, with a `tools/list_changed` notification each time).
"""

import asyncio

import pytest
from fastmcp import Client
from fastmcp.client.messages import MessageHandler
from mcp.shared.exceptions import McpError

import catalog
import server
from server import mcp


@pytest.fixture(autouse=True)
def fresh():
    """Reseed the catalog and make sure the admin tool is not left registered."""
    catalog.reset()
    if server._admin_on:
        mcp.remove_tool("delete_book")
        server._admin_on = False


class Recorder(MessageHandler):
    def __init__(self):
        self.events: list[str] = []

    async def on_tool_list_changed(self, message) -> None:
        self.events.append("tools/list_changed")


async def test_templates_and_static_resources_are_listed():
    async with Client(mcp) as client:
        templates = {t.uriTemplate for t in await client.list_resource_templates()}
        assert templates == {"book://{book_id}", "books://genre/{genre}"}
        statics = {str(r.uri) for r in await client.list_resources()}
        assert "books://all" in statics


async def test_read_book_template():
    async with Client(mcp) as client:
        res = await client.read_resource("book://2")
        assert '"title": "Deep Learning"' in res[0].text


async def test_read_genre_template():
    async with Client(mcp) as client:
        res = await client.read_resource("books://genre/tech")
        assert '"genre": "tech"' in res[0].text
        assert res[0].text.count('"id"') == 2  # two tech books


async def test_unknown_book_resource_errors():
    async with Client(mcp) as client:
        with pytest.raises(McpError):
            await client.read_resource("book://99")


async def test_enable_admin_registers_tool_and_notifies():
    rec = Recorder()
    async with Client(mcp, message_handler=rec) as client:
        before = {t.name for t in await client.list_tools()}
        assert "delete_book" not in before

        await client.call_tool("enable_admin", {})
        await asyncio.sleep(0.05)

        after = {t.name for t in await client.list_tools()}
        assert "delete_book" in after
        assert rec.events == ["tools/list_changed"]


async def test_delete_book_works_once_enabled():
    async with Client(mcp) as client:
        await client.call_tool("enable_admin", {})
        res = await client.call_tool("delete_book", {"book_id": 1})
        assert res.structured_content == {"book_id": 1, "deleted": True}
        assert catalog.get(1) is None


async def test_disable_admin_removes_tool_and_notifies():
    rec = Recorder()
    async with Client(mcp, message_handler=rec) as client:
        await client.call_tool("enable_admin", {})
        await asyncio.sleep(0.05)
        await client.call_tool("disable_admin", {})
        await asyncio.sleep(0.05)

        names = {t.name for t in await client.list_tools()}
        assert "delete_book" not in names
        assert rec.events == ["tools/list_changed", "tools/list_changed"]


async def test_enable_admin_is_idempotent():
    rec = Recorder()
    async with Client(mcp, message_handler=rec) as client:
        await client.call_tool("enable_admin", {})
        await client.call_tool("enable_admin", {})  # second call is a no-op
        await asyncio.sleep(0.05)
        assert rec.events == ["tools/list_changed"]  # only one notification

Detailed breakdown

  • fresh is autouse: it reseeds the catalog and, if a previous test left admin mode on, removes delete_book and clears the flag. The server object is a module global, so this cleanup keeps tests independent.
  • test_unknown_book_resource_errors confirms a template that raises reaches the client as an McpError on the read.
  • test_enable_admin_is_idempotent proves the guard works: two enables produce exactly one notification, so a client is not spammed on a repeat call.

Step 7: 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:  ## Run the in-memory client: read templates, watch list_changed
	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 8: Run everything

make          # prints the help screen
make demo     # runs client.py (output shown in Step 5)
make test     # runs the suite

The suite passes:

........                                                                 [100%]
8 passed in 0.54s

Notes on dynamic servers

  • Resources and prompts change the same way. Register or drop the component, then send ResourceListChangedNotification or PromptListChangedNotification. A FastMCP client handles them with on_resource_list_changed and on_prompt_list_changed.
  • Mutation and notification are separate steps. add_tool / remove_tool change what the server would answer on the next tools/list, but they do not tell anyone. Without the notification, a client keeps calling the old list until it reconnects. Send the notification whenever a change should be visible.
  • This changes the tools globally. Because the toggle mutates the shared server object, every connected session sees delete_book after any one client enables it. For per-session capabilities (an admin sees the tool, others do not), FastMCP’s ctx.enable_components / ctx.disable_components apply visibility rules to the current session only and notify just that session.
  • Declare the capability. A server advertises listChanged support during initialization; FastMCP sets this for you when you register components, so a client knows to honor the notifications.

Troubleshooting

  • AttributeError: 'FastMCP' object has no attribute 'types'. The server variable mcp shadowed the mcp package. Import notification classes by name: from mcp.types import ToolListChangedNotification.
  • The client never reacts to a change. Either no notification was sent (check for the send_notification call after the mutation) or the client has no message_handler. A client with no handler still works but ignores the nudge.
  • contents[0] must be ResourceContent, got dict. A resource returned a bare list. Wrap it in a dict (or another single object) so the read produces one content item.
  • A template never shows up. Templates appear in list_resource_templates, not list_resources. A URI with no {placeholder} is a static resource; one with a placeholder is a template.
  • ModuleNotFoundError: No module named 'server' under pytest. tests/ needs an __init__.py so pytest puts the project root on sys.path.

Recap

The server started with a fixed catalog but did not stay static. Resource templates served every book from two parameterized URIs, and a runtime toggle registered and removed a tool while a client was connected, sending a tools/list_changed notification each time so the client re-listed instead of guessing. The same pattern extends to resources and prompts, and to per-session visibility when a change should not be global.

Next improvements:

  • Add a prompt template and send prompts/list_changed when it appears.
  • Gate enable_admin behind real authorization so only an admin identity can expand the tool set.
  • Switch to ctx.enable_components so the admin tool is visible only to the session that unlocked it.