The problem this solves
Every client so far called list_tools() once, right after connecting, and assumed that list stayed accurate for the whole conversation. A server's tools aren't always static, though: a tool might unlock another one, a permission might change, an integration might come online partway through a session. Re-polling list_tools() on a timer would work, but it's wasteful and slow to react. Notifications let the server push a heads-up instead.
A tool that changes the tool list
@mcp.tool()async def unlock_secret_tool(ctx: Context) -> str: """Unlock a hidden bonus tool.""" def multiply(a: int, b: int) -> int: return a * b multiply.__doc__ = "Multiply two numbers." mcp.add_tool(multiply, name="multiply") await ctx.session.send_tool_list_changed() return "Unlocked the multiply tool!"mcp.add_tool(fn, name=...) registers a new tool at runtime, the programmatic version of decorating a function with @mcp.tool() at import time. Adding it doesn't announce it by itself, that's what ctx.session.send_tool_list_changed() does: it sends a notifications/tools/list_changed message, no response expected, to whichever client is currently connected.
Receiving it on the client
A ClientSession accepts a message_handler, a callback invoked for every notification (and out-of-band message) that arrives outside the normal request/response flow.
notifications_seen = []
async def message_handler(message): notifications_seen.append(message)
async with ClientSession(read, write, message_handler=message_handler) as session: ...Call unlock_secret_tool, and shortly after, message_handler fires with a ServerNotification wrapping a ToolListChangedNotification. The correct reaction, the same one a real host would take, is to call list_tools() again rather than trust the stale list from before.
Why this needs to be explicit
Notice mcp.add_tool() alone did not trigger anything, the server had to call send_tool_list_changed() itself. MCP's notification system is opt-in and best-effort on both sides: a server chooses when a change is worth announcing, and a client isn't guaranteed to receive every notification (say, across a reconnect), so a well-behaved client still refreshes periodically rather than relying on notifications alone.
Checkpoint
mcp.add_tool(fn, name=...): registers a tool at runtime, same registration@mcp.tool()does at import time.ctx.session.send_tool_list_changed(): explicitly announces a change, this does not happen automatically.message_handler: aClientSessioncallback for notifications arriving outside the normal request/response flow.- Notifications are a best-effort heads-up, not a replacement for occasionally re-checking
list_tools().
If anything here still feels unclear, ask before moving to Lesson 23, a server asking the user a question mid-call.