Add automatic MCP list change notifications and client message handling

Implements comprehensive notification system for tools, resources, and prompts with automatic client updates and flexible message handlers.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeremiah Lowin 2025-06-24 22:33:06 -04:00
commit 38036b1f42
22 changed files with 1013 additions and 58 deletions

View file

@ -15,10 +15,10 @@ from pydantic import AnyUrl
import fastmcp
from fastmcp.client.logging import (
LogHandler,
MessageHandler,
create_log_callback,
default_log_handler,
)
from fastmcp.client.messages import MessageHandler, MessageHandlerFnT
from fastmcp.client.progress import ProgressHandler, default_progress_handler
from fastmcp.client.roots import (
RootsHandler,
@ -143,7 +143,7 @@ class Client(Generic[ClientTransportT]):
roots: RootsList | RootsHandler | None = None,
sampling_handler: SamplingHandler | None = None,
log_handler: LogHandler | None = None,
message_handler: MessageHandler | None = None,
message_handler: MessageHandlerFnT | MessageHandler | None = None,
progress_handler: ProgressHandler | None = None,
timeout: datetime.timedelta | float | int | None = None,
init_timeout: datetime.timedelta | float | int | None = None,

View file

@ -1,7 +1,7 @@
from collections.abc import Awaitable, Callable
from typing import TypeAlias
from mcp.client.session import LoggingFnT, MessageHandlerFnT
from mcp.client.session import LoggingFnT
from mcp.types import LoggingMessageNotificationParams
from fastmcp.utilities.logging import get_logger
@ -10,7 +10,6 @@ logger = get_logger(__name__)
LogMessage: TypeAlias = LoggingMessageNotificationParams
LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]]
MessageHandler: TypeAlias = MessageHandlerFnT
async def default_log_handler(message: LogMessage) -> None:

View file

@ -0,0 +1,122 @@
from collections.abc import Awaitable, Coroutine
from typing import Any, TypeAlias, Union
import mcp.types
from mcp.client.session import MessageHandlerFnT
from mcp.shared.session import RequestResponder
Message: TypeAlias = (
RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
| mcp.types.ServerNotification
| Exception
)
MessageHandlerFn: TypeAlias = MessageHandlerFnT
class MessageHandler:
"""
This class is used to handle MCP messages sent to the client. It is used to handle all messages,
requests, notifications, and exceptions. Users can override any of the hooks
"""
def __call__(self, message: Message) -> Coroutine[Any, Any, None]:
return self.dispatch(message)
async def dispatch(self, message: Message) -> None:
# handle all messages
await self.on_message(message)
match message:
# requests
case RequestResponder():
# handle all requests
await self.on_request(message)
# handle specific requests
match message.request.root:
case mcp.types.PingRequest():
await self.on_ping(message.request.root)
case mcp.types.ListRootsRequest():
await self.on_list_roots(message.request.root)
case mcp.types.CreateMessageRequest():
await self.on_create_message(message.request.root)
# notifications
case mcp.types.ServerNotification():
# handle all notifications
await self.on_notification(message)
# handle specific notifications
match message.root:
case mcp.types.CancelledNotification():
await self.on_cancelled(message.root)
case mcp.types.ProgressNotification():
await self.on_progress(message.root)
case mcp.types.LoggingMessageNotification():
await self.on_logging_message(message.root)
case mcp.types.ToolListChangedNotification():
await self.on_tool_list_changed(message.root)
case mcp.types.ResourceListChangedNotification():
await self.on_resource_list_changed(message.root)
case mcp.types.PromptListChangedNotification():
await self.on_prompt_list_changed(message.root)
case mcp.types.ResourceUpdatedNotification():
await self.on_resource_updated(message.root)
case Exception():
await self.on_exception(message)
async def on_message(self, message: Message) -> None:
pass
async def on_request(
self, message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
) -> None:
pass
async def on_ping(self, message: mcp.types.PingRequest) -> None:
pass
async def on_list_roots(self, message: mcp.types.ListRootsRequest) -> None:
pass
async def on_create_message(self, message: mcp.types.CreateMessageRequest) -> None:
pass
async def on_notification(self, message: mcp.types.ServerNotification) -> None:
pass
async def on_exception(self, message: Exception) -> None:
pass
async def on_progress(self, message: mcp.types.ProgressNotification) -> None:
pass
async def on_logging_message(
self, message: mcp.types.LoggingMessageNotification
) -> None:
pass
async def on_tool_list_changed(
self, message: mcp.types.ToolListChangedNotification
) -> None:
pass
async def on_resource_list_changed(
self, message: mcp.types.ResourceListChangedNotification
) -> None:
pass
async def on_prompt_list_changed(
self, message: mcp.types.PromptListChangedNotification
) -> None:
pass
async def on_resource_updated(
self, message: mcp.types.ResourceUpdatedNotification
) -> None:
pass
async def on_cancelled(self, message: mcp.types.CancelledNotification) -> None:
pass

View file

@ -24,6 +24,7 @@ from typing_extensions import Unpack
import fastmcp
from fastmcp.client.auth.bearer import BearerAuth
from fastmcp.client.auth.oauth import OAuth
from fastmcp.client.messages import MessageHandler
from fastmcp.server.dependencies import get_http_headers
from fastmcp.server.server import FastMCP
from fastmcp.utilities.logging import get_logger
@ -56,7 +57,7 @@ class SessionKwargs(TypedDict, total=False):
sampling_callback: SamplingFnT | None
list_roots_callback: ListRootsFnT | None
logging_callback: LoggingFnT | None
message_handler: MessageHandlerFnT | None
message_handler: MessageHandlerFnT | MessageHandler | None
client_info: mcp.types.Implementation | None

View file

@ -70,6 +70,22 @@ class Prompt(FastMCPComponent, ABC):
default=None, description="Arguments that can be passed to the prompt"
)
def enable(self) -> None:
super().enable()
try:
context = get_context()
context._queue_prompt_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def disable(self) -> None:
super().disable()
try:
context = get_context()
context._queue_prompt_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def to_mcp_prompt(self, **overrides: Any) -> MCPPrompt:
"""Convert the prompt to an MCP prompt."""
arguments = [

View file

@ -44,6 +44,22 @@ class Resource(FastMCPComponent, abc.ABC):
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
)
def enable(self) -> None:
super().enable()
try:
context = get_context()
context._queue_resource_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def disable(self) -> None:
super().disable()
try:
context = get_context()
context._queue_resource_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
@staticmethod
def from_function(
fn: Callable[[], Any],

View file

@ -15,7 +15,7 @@ from pydantic import (
validate_call,
)
from fastmcp.resources.types import Resource
from fastmcp.resources.resource import Resource
from fastmcp.server.dependencies import get_context
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.json_schema import compress_schema
@ -65,6 +65,22 @@ class ResourceTemplate(FastMCPComponent):
def __repr__(self) -> str:
return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
def enable(self) -> None:
super().enable()
try:
context = get_context()
context._queue_resource_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def disable(self) -> None:
super().disable()
try:
context = get_context()
context._queue_resource_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
@staticmethod
def from_function(
fn: Callable[..., Any],

View file

@ -1,12 +1,13 @@
from __future__ import annotations as _annotations
import asyncio
import warnings
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass
from mcp import LoggingLevel
from mcp import LoggingLevel, ServerSession
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.lowlevel.server import request_ctx
from mcp.shared.context import RequestContext
@ -30,6 +31,7 @@ from fastmcp.utilities.types import MCPContent
logger = get_logger(__name__)
_current_context: ContextVar[Context | None] = ContextVar("context", default=None)
_flush_lock = asyncio.Lock()
@contextmanager
@ -80,16 +82,20 @@ class Context:
def __init__(self, fastmcp: FastMCP):
self.fastmcp = fastmcp
self._tokens: list[Token] = []
self._notification_queue: set[str] = set() # Dedupe notifications
def __enter__(self) -> Context:
async def __aenter__(self) -> Context:
"""Enter the context manager and set this context as the current context."""
# Always set this context and save the token
token = _current_context.set(self)
self._tokens.append(token)
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
"""Exit the context manager and reset the most recent token."""
# Flush any remaining notifications before exiting
await self._flush_notifications()
if self._tokens:
token = self._tokens.pop()
_current_context.reset(token)
@ -124,7 +130,7 @@ class Context:
if progress_token is None:
return
await self.request_context.session.send_progress_notification(
await self.session.send_progress_notification(
progress_token=progress_token,
progress=progress,
total=total,
@ -160,7 +166,7 @@ class Context:
"""
if level is None:
level = "info"
await self.request_context.session.send_log_message(
await self.session.send_log_message(
level=level, data=message, logger=logger_name
)
@ -210,7 +216,7 @@ class Context:
return None
@property
def session(self):
def session(self) -> ServerSession:
"""Access to the underlying session for advanced usage."""
return self.request_context.session
@ -233,9 +239,21 @@ class Context:
async def list_roots(self) -> list[Root]:
"""List the roots available to the server, as indicated by the client."""
result = await self.request_context.session.list_roots()
result = await self.session.list_roots()
return result.roots
async def send_tool_list_changed(self) -> None:
"""Send a tool list changed notification to the client."""
await self.session.send_tool_list_changed()
async def send_resource_list_changed(self) -> None:
"""Send a resource list changed notification to the client."""
await self.session.send_resource_list_changed()
async def send_prompt_list_changed(self) -> None:
"""Send a prompt list changed notification to the client."""
await self.session.send_prompt_list_changed()
async def sample(
self,
messages: str | list[str | SamplingMessage],
@ -269,7 +287,7 @@ class Context:
for m in messages
]
result: CreateMessageResult = await self.request_context.session.create_message(
result: CreateMessageResult = await self.session.create_message(
messages=sampling_messages,
system_prompt=system_prompt,
temperature=temperature,
@ -294,6 +312,52 @@ class Context:
return fastmcp.server.dependencies.get_http_request()
def _queue_tool_list_changed(self) -> None:
"""Queue a tool list changed notification."""
self._notification_queue.add("notifications/tools/list_changed")
self._try_flush_notifications()
def _queue_resource_list_changed(self) -> None:
"""Queue a resource list changed notification."""
self._notification_queue.add("notifications/resources/list_changed")
self._try_flush_notifications()
def _queue_prompt_list_changed(self) -> None:
"""Queue a prompt list changed notification."""
self._notification_queue.add("notifications/prompts/list_changed")
self._try_flush_notifications()
def _try_flush_notifications(self) -> None:
"""Synchronous method that attempts to flush notifications if we're in an async context."""
try:
# Check if we're in an async context
loop = asyncio.get_running_loop()
if loop and not loop.is_running():
return
# Schedule flush as a task (fire-and-forget)
asyncio.create_task(self._flush_notifications())
except RuntimeError:
# No event loop - will flush later
pass
async def _flush_notifications(self) -> None:
"""Send all queued notifications."""
async with _flush_lock:
if not self._notification_queue:
return
try:
if "notifications/tools/list_changed" in self._notification_queue:
await self.session.send_tool_list_changed()
if "notifications/resources/list_changed" in self._notification_queue:
await self.session.send_resource_list_changed()
if "notifications/prompts/list_changed" in self._notification_queue:
await self.session.send_prompt_list_changed()
self._notification_queue.clear()
except Exception:
# Don't let notification failures break the request
pass
def _parse_model_preferences(
self, model_preferences: ModelPreferences | str | list[str] | None
) -> ModelPreferences | None:

View file

@ -0,0 +1,35 @@
from typing import Any
from mcp.server.lowlevel.server import (
LifespanResultT,
NotificationOptions,
RequestT,
Server,
)
from mcp.server.models import InitializationOptions
class LowLevelServer(Server[LifespanResultT, RequestT]):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# FastMCP servers support notifications for all components
self.notification_options = NotificationOptions(
prompts_changed=True,
resources_changed=True,
tools_changed=True,
)
def create_initialization_options(
self,
notification_options: NotificationOptions | None = None,
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
**kwargs: Any,
) -> InitializationOptions:
# ensure we use the FastMCP notification options
if notification_options is None:
notification_options = self.notification_options
return super().create_initialization_options(
notification_options=notification_options,
experimental_capabilities=experimental_capabilities,
**kwargs,
)

View file

@ -23,7 +23,6 @@ import mcp.types
import uvicorn
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
from mcp.server.lowlevel.server import Server as MCPServer
from mcp.server.stdio import stdio_server
from mcp.types import (
AnyFunction,
@ -54,6 +53,7 @@ from fastmcp.server.http import (
create_sse_app,
create_streamable_http_app,
)
from fastmcp.server.low_level import LowLevelServer
from fastmcp.server.middleware import Middleware, MiddlewareContext
from fastmcp.settings import Settings
from fastmcp.tools import ToolManager
@ -99,10 +99,12 @@ def _lifespan_wrapper(
[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
],
) -> Callable[
[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
[LowLevelServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
]:
@asynccontextmanager
async def wrap(s: MCPServer[LifespanResultT]) -> AsyncIterator[LifespanResultT]:
async def wrap(
s: LowLevelServer[LifespanResultT],
) -> AsyncIterator[LifespanResultT]:
async with AsyncExitStack() as stack:
context = await stack.enter_async_context(lifespan(app))
yield context
@ -179,7 +181,7 @@ class FastMCP(Generic[LifespanResultT]):
lifespan = default_lifespan
else:
self._has_lifespan = True
self._mcp_server = MCPServer[LifespanResultT](
self._mcp_server = LowLevelServer[LifespanResultT](
name=name or "FastMCP",
version=version,
instructions=instructions,
@ -427,7 +429,7 @@ class FastMCP(Generic[LifespanResultT]):
async def _mcp_list_tools(self) -> list[MCPTool]:
logger.debug("Handler called: list_tools")
with fastmcp.server.context.Context(fastmcp=self):
async with fastmcp.server.context.Context(fastmcp=self):
tools = await self._list_tools()
return [tool.to_mcp_tool(name=tool.key) for tool in tools]
@ -450,7 +452,7 @@ class FastMCP(Generic[LifespanResultT]):
return mcp_tools
with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
# Create the middleware context.
mw_context = MiddlewareContext(
message=mcp.types.ListToolsRequest(method="tools/list"),
@ -466,7 +468,7 @@ class FastMCP(Generic[LifespanResultT]):
async def _mcp_list_resources(self) -> list[MCPResource]:
logger.debug("Handler called: list_resources")
with fastmcp.server.context.Context(fastmcp=self):
async with fastmcp.server.context.Context(fastmcp=self):
resources = await self._list_resources()
return [
resource.to_mcp_resource(uri=resource.key) for resource in resources
@ -491,7 +493,7 @@ class FastMCP(Generic[LifespanResultT]):
return mcp_resources
with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
# Create the middleware context.
mw_context = MiddlewareContext(
message={}, # List resources doesn't have parameters
@ -507,7 +509,7 @@ class FastMCP(Generic[LifespanResultT]):
async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
logger.debug("Handler called: list_resource_templates")
with fastmcp.server.context.Context(fastmcp=self):
async with fastmcp.server.context.Context(fastmcp=self):
templates = await self._list_resource_templates()
return [
template.to_mcp_template(uriTemplate=template.key)
@ -533,7 +535,7 @@ class FastMCP(Generic[LifespanResultT]):
return mcp_templates
with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
# Create the middleware context.
mw_context = MiddlewareContext(
message={}, # List resource templates doesn't have parameters
@ -549,7 +551,7 @@ class FastMCP(Generic[LifespanResultT]):
async def _mcp_list_prompts(self) -> list[MCPPrompt]:
logger.debug("Handler called: list_prompts")
with fastmcp.server.context.Context(fastmcp=self):
async with fastmcp.server.context.Context(fastmcp=self):
prompts = await self._list_prompts()
return [prompt.to_mcp_prompt(name=prompt.key) for prompt in prompts]
@ -572,7 +574,7 @@ class FastMCP(Generic[LifespanResultT]):
return mcp_prompts
with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
# Create the middleware context.
mw_context = MiddlewareContext(
message=mcp.types.ListPromptsRequest(method="prompts/list"),
@ -602,7 +604,7 @@ class FastMCP(Generic[LifespanResultT]):
"""
logger.debug("Handler called: call_tool %s with %s", key, arguments)
with fastmcp.server.context.Context(fastmcp=self):
async with fastmcp.server.context.Context(fastmcp=self):
try:
return await self._call_tool(key, arguments)
except DisabledError:
@ -643,7 +645,7 @@ class FastMCP(Generic[LifespanResultT]):
"""
logger.debug("Handler called: read_resource %s", uri)
with fastmcp.server.context.Context(fastmcp=self):
async with fastmcp.server.context.Context(fastmcp=self):
try:
return await self._read_resource(uri)
except DisabledError:
@ -698,7 +700,7 @@ class FastMCP(Generic[LifespanResultT]):
"""
logger.debug("Handler called: get_prompt %s with %s", name, arguments)
with fastmcp.server.context.Context(fastmcp=self):
async with fastmcp.server.context.Context(fastmcp=self):
try:
return await self._get_prompt(name, arguments)
except DisabledError:
@ -747,6 +749,15 @@ class FastMCP(Generic[LifespanResultT]):
self._tool_manager.add_tool(tool)
self._cache.clear()
# Send notification if we're in a request context
try:
from fastmcp.server.dependencies import get_context
context = get_context()
context._queue_tool_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def remove_tool(self, name: str) -> None:
"""Remove a tool from the server.
@ -759,6 +770,15 @@ class FastMCP(Generic[LifespanResultT]):
self._tool_manager.remove_tool(name)
self._cache.clear()
# Send notification if we're in a request context
try:
from fastmcp.server.dependencies import get_context
context = get_context()
context._queue_tool_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
@overload
def tool(
self,
@ -911,6 +931,15 @@ class FastMCP(Generic[LifespanResultT]):
self._resource_manager.add_resource(resource)
self._cache.clear()
# Send notification if we're in a request context
try:
from fastmcp.server.dependencies import get_context
context = get_context()
context._queue_resource_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def add_template(self, template: ResourceTemplate) -> None:
"""Add a resource template to the server.
@ -919,6 +948,15 @@ class FastMCP(Generic[LifespanResultT]):
"""
self._resource_manager.add_template(template)
# Send notification if we're in a request context
try:
from fastmcp.server.dependencies import get_context
context = get_context()
context._queue_resource_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def add_resource_fn(
self,
fn: AnyFunction,
@ -1087,6 +1125,15 @@ class FastMCP(Generic[LifespanResultT]):
self._prompt_manager.add_prompt(prompt)
self._cache.clear()
# Send notification if we're in a request context
try:
from fastmcp.server.dependencies import get_context
context = get_context()
context._queue_prompt_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
@overload
def prompt(
self,

View file

@ -46,6 +46,22 @@ class Tool(FastMCPComponent):
default=None, description="Optional custom serializer for tool results"
)
def enable(self) -> None:
super().enable()
try:
context = get_context()
context._queue_tool_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def disable(self) -> None:
super().disable()
try:
context = get_context()
context._queue_tool_list_changed() # type: ignore[private-use]
except RuntimeError:
pass # No context available
def to_mcp_tool(self, **overrides: Any) -> MCPTool:
kwargs = {
"name": self.name,