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_changednotifications/resources/list_changednotifications/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 basesearch_bookstool, andenable_admin/disable_admintools that register and removedelete_bookat 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
pytestsuite covering template listing/reading and the notification 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 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
fastmcpis the only runtime dependency. It provides theContextobject used to send notifications and theTool.from_functionhelper 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
Bookis a Pydantic model; the resources serialize it withmodel_dump().reset()restores the three seed books, so a test that deletes book1does not affect the next test.by_genrelowercases the query, sobooks://genre/Techandbooks://genre/techbehave 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
mcpis the convention, but it shadows themcppackage. Writingimport mcpand thenmcp.types.ToolListChangedNotificationwould resolvemcpto the server and raiseAttributeError: 'FastMCP' object has no attribute 'types'. Import the notification class by name to avoid the collision. book_resourceis a template: the{book_id}in the URI becomes the function’sstrargument (URI parts are strings, so it casts withint()). One function serves every id. A missing id raisesToolError, which the client receives as a resource-read error.genre_resourcereturns a single dict, not a list. A resource that returns a bare list is read as one content item per element, which fails with aResourceContenterror; wrapping the list in a dict returns one JSON payload.enable_admin/disable_admintake aContextparameter. FastMCP injects it, andctx.send_notification(...)pushes the message to the current session. The_admin_onflag makes the toggles idempotent: callingenable_admintwice registers the tool once and notifies once.delete_bookis a plain function, not decorated with@mcp.tool. It is wrapped withTool.from_functionand registered only when admin mode turns on.mcp.add_toolandmcp.remove_toolchange the catalog but do not notify on their own — the explicitsend_notificationcall 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
WatchersubclassesMessageHandlerand overrideson_tool_list_changed. That callback fires whenever the server sends the notification; the base class also hason_resource_list_changedandon_prompt_list_changed.list_resourcesvslist_resource_templatesare separate calls. Templates (with{...}) never appear inlist_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 nextlist_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 = 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 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
freshis autouse: it reseeds the catalog and, if a previous test left admin mode on, removesdelete_bookand clears the flag. The server object is a module global, so this cleanup keeps tests independent.test_unknown_book_resource_errorsconfirms a template that raises reaches the client as anMcpErroron the read.test_enable_admin_is_idempotentproves 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 := helpmakes a baremakeprint the help screen. Recipe bodies must be tab-indented ormakeerrors.
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
ResourceListChangedNotificationorPromptListChangedNotification. A FastMCP client handles them withon_resource_list_changedandon_prompt_list_changed. - Mutation and notification are separate steps.
add_tool/remove_toolchange what the server would answer on the nexttools/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_bookafter any one client enables it. For per-session capabilities (an admin sees the tool, others do not), FastMCP’sctx.enable_components/ctx.disable_componentsapply visibility rules to the current session only and notify just that session. - Declare the capability. A server advertises
listChangedsupport 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 variablemcpshadowed themcppackage. 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_notificationcall after the mutation) or the client has nomessage_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, notlist_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__.pyso pytest puts the project root onsys.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_changedwhen it appears. - Gate
enable_adminbehind real authorization so only an admin identity can expand the tool set. - Switch to
ctx.enable_componentsso the admin tool is visible only to the session that unlocked it.