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

@ -34,3 +34,4 @@ async with Client(transport=StreamableHttpTransport(server_url)) as client:
- You must always run pre-commit if you open a PR, because it is run as part of a required check.
- When opening PRs, apply labels appropriately for bugs/breaking changes/enhancements/features. Generally, improvements are enhancements (not features) unless told otherwise.
- NEVER modify files in docs/python-sdk/**, as they are auto-generated.
- Use # type: ignore[attr-defined] in unit tests when accessing an MCP result of indeterminate type instead of asserting its type

129
docs/clients/messages.mdx Normal file
View file

@ -0,0 +1,129 @@
---
title: Message Handling
sidebarTitle: Messages
description: Handle MCP messages, requests, and notifications with custom message handlers.
icon: envelope
---
import { VersionBadge } from "/snippets/version-badge.mdx";
<VersionBadge version="2.9.0" />
MCP clients can receive various types of messages from servers, including requests that need responses and notifications that don't. The message handler provides a unified way to process all these messages.
## Function-Based Handler
The simplest way to handle messages is with a function that receives all messages:
```python
from fastmcp import Client
async def message_handler(message):
"""Handle all MCP messages from the server."""
if hasattr(message, 'root'):
method = message.root.method
print(f"Received: {method}")
# Handle specific notifications
if method == "notifications/tools/list_changed":
print("Tools have changed - might want to refresh tool cache")
elif method == "notifications/resources/list_changed":
print("Resources have changed")
client = Client(
"my_mcp_server.py",
message_handler=message_handler,
)
```
## Message Handler Class
For fine-grained targeting, FastMCP provides a `MessageHandler` class you can subclass to take advantage of specific hooks:
```python
from fastmcp import Client
from fastmcp.client.messages import MessageHandler
import mcp.types
class MyMessageHandler(MessageHandler):
async def on_tool_list_changed(
self, notification: mcp.types.ToolListChangedNotification
) -> None:
"""Handle tool list changes specifically."""
print("Tool list changed - refreshing available tools")
client = Client(
"my_mcp_server.py",
message_handler=MyMessageHandler(),
)
```
### Available Handler Methods
All handler methods receive a single argument - the specific message type:
<Card icon="code" title="Message Handler Methods">
<ResponseField name="on_message(message)" type="Any MCP message">
Called for ALL messages (requests and notifications)
</ResponseField>
<ResponseField name="on_request(request)" type="mcp.types.ClientRequest">
Called for requests that expect responses
</ResponseField>
<ResponseField name="on_notification(notification)" type="mcp.types.ServerNotification">
Called for notifications (fire-and-forget)
</ResponseField>
<ResponseField name="on_tool_list_changed(notification)" type="mcp.types.ToolListChangedNotification">
Called when the server's tool list changes
</ResponseField>
<ResponseField name="on_resource_list_changed(notification)" type="mcp.types.ResourceListChangedNotification">
Called when the server's resource list changes
</ResponseField>
<ResponseField name="on_prompt_list_changed(notification)" type="mcp.types.PromptListChangedNotification">
Called when the server's prompt list changes
</ResponseField>
<ResponseField name="on_progress(notification)" type="mcp.types.ProgressNotification">
Called for progress updates during long-running operations
</ResponseField>
<ResponseField name="on_logging_message(notification)" type="mcp.types.LoggingMessageNotification">
Called for log messages from the server
</ResponseField>
</Card>
## Example: Handling Tool Changes
Here's a practical example of handling tool list changes:
```python
from fastmcp.client.messages import MessageHandler
import mcp.types
class ToolCacheHandler(MessageHandler):
def __init__(self):
self.cached_tools = []
async def on_tool_list_changed(
self, notification: mcp.types.ToolListChangedNotification
) -> None:
"""Clear tool cache when tools change."""
print("Tools changed - clearing cache")
self.cached_tools = [] # Force refresh on next access
client = Client("server.py", message_handler=ToolCacheHandler())
```
## Handling Requests
While the message handler receives server-initiated requests, for most use cases you should use the dedicated callback parameters instead:
- **Sampling requests**: Use [`sampling_handler`](/clients/sampling)
- **Progress requests**: Use [`progress_handler`](/clients/progress)
- **Log requests**: Use [`log_handler`](/clients/logging)
The message handler is primarily for monitoring and handling notifications rather than responding to requests.

View file

@ -76,9 +76,7 @@
{
"group": "Authentication",
"icon": "shield-check",
"pages": [
"servers/auth/bearer"
]
"pages": ["servers/auth/bearer"]
},
"servers/middleware",
"servers/openapi",
@ -87,10 +85,7 @@
{
"group": "Deployment",
"icon": "upload",
"pages": [
"deployment/running-server",
"deployment/asgi"
]
"pages": ["deployment/running-server", "deployment/asgi"]
}
]
},
@ -114,6 +109,7 @@
"clients/logging",
"clients/progress",
"clients/sampling",
"clients/messages",
"clients/roots"
]
},
@ -121,10 +117,7 @@
{
"group": "Authentication",
"icon": "user-shield",
"pages": [
"clients/auth/oauth",
"clients/auth/bearer"
]
"pages": ["clients/auth/oauth", "clients/auth/bearer"]
}
]
},
@ -163,17 +156,12 @@
},
{
"anchor": "What's New",
"pages": [
"updates",
"changelog"
]
"pages": ["updates", "changelog"]
},
{
"anchor": "Community",
"icon": "users",
"pages": [
"community/showcase"
]
"pages": ["community/showcase"]
}
]
},

View file

@ -275,6 +275,25 @@ async def generate_example(concept: str, ctx: Context) -> str:
See [Client Sampling](/clients/client#llm-sampling) for more details on how clients handle these requests.
### Component Changes
<VersionBadge version="2.9.1" />
FastMCP automatically sends list change notifications when components (such as tools, resources, or prompts) are added, removed, enabled, or disabled. In rare cases where you need to manually trigger these notifications, you can use the context methods:
```python
@mcp.tool
async def custom_tool_management(ctx: Context) -> str:
"""Example of manual notification after custom tool changes."""
# After making custom changes to tools
await ctx.send_tool_list_changed()
await ctx.send_resource_list_changed()
await ctx.send_prompt_list_changed()
return "Notifications sent"
```
These methods are primarily used internally by FastMCP's automatic notification system and most users will not need to invoke them directly.
### Request Information
Access metadata about the current request and client.

View file

@ -237,7 +237,8 @@ def seasonal_prompt(): return "Happy Holidays!"
seasonal_prompt.disable()
seasonal_prompt.enable()
```
### Asynchronous Prompts
### Async Prompts
FastMCP seamlessly supports both standard (`def`) and asynchronous (`async def`) functions as prompts.
@ -280,7 +281,26 @@ async def generate_report_request(report_type: str, ctx: Context) -> str:
For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
### Notifications
<VersionBadge version="2.9.1" />
FastMCP automatically sends `notifications/prompts/list_changed` notifications to connected clients when prompts are added, enabled, or disabled. This allows clients to stay up-to-date with the current prompt set without manually polling for changes.
```python
@mcp.prompt
def example_prompt() -> str:
return "Hello!"
# These operations trigger notifications:
mcp.add_prompt(example_prompt) # Sends prompts/list_changed notification
example_prompt.disable() # Sends prompts/list_changed notification
example_prompt.enable() # Sends prompts/list_changed notification
```
Notifications are only sent when these operations occur within an active MCP request context (e.g., when called from within a tool or other MCP operation). Operations performed during server initialization do not trigger notifications.
Clients can handle these notifications using a [message handler](/clients/messages) to automatically refresh their prompt lists or update their interfaces.
## Server Behavior

View file

@ -141,6 +141,7 @@ get_config.disable()
get_config.enable()
```
### Accessing MCP Context
<VersionBadge version="2.2.5" />
@ -172,7 +173,7 @@ async def get_details(name: str, ctx: Context) -> dict:
For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
### Asynchronous Resources
### Async Resources
Use `async def` for resource functions that perform I/O operations (e.g., reading from a database or network) to avoid blocking the server.
@ -278,6 +279,27 @@ mcp.add_resource(special_resource, key="internal://data-v2") # Will be stored a
Note that this parameter is only available when using `add_resource()` directly and not through the `@resource` decorator, as URIs are provided explicitly when using the decorator.
### Notifications
<VersionBadge version="2.9.1" />
FastMCP automatically sends `notifications/resources/list_changed` notifications to connected clients when resources or templates are added, enabled, or disabled. This allows clients to stay up-to-date with the current resource set without manually polling for changes.
```python
@mcp.resource("data://example")
def example_resource() -> str:
return "Hello!"
# These operations trigger notifications:
mcp.add_resource(example_resource) # Sends resources/list_changed notification
example_resource.disable() # Sends resources/list_changed notification
example_resource.enable() # Sends resources/list_changed notification
```
Notifications are only sent when these operations occur within an active MCP request context (e.g., when called from within a tool or other MCP operation). Operations performed during server initialization do not trigger notifications.
Clients can handle these notifications using a [message handler](/clients/messages) to automatically refresh their resource lists or update their interfaces.
## Resource Templates
Resource Templates allow clients to request resources whose content depends on parameters embedded in the URI. Define a template using the **same `@mcp.resource` decorator**, but include `{parameter_name}` placeholders in the URI string and add corresponding arguments to your function signature.

View file

@ -415,6 +415,28 @@ FastMCP supports these standard annotations:
Remember that annotations help make better user experiences but should be treated as advisory hints. They help client applications present appropriate UI elements and safety controls, but won't enforce security boundaries on their own. Always focus on making your annotations accurately represent what your tool actually does.
### Notifications
<VersionBadge version="2.9.1" />
FastMCP automatically sends `notifications/tools/list_changed` notifications to connected clients when tools are added, removed, enabled, or disabled. This allows clients to stay up-to-date with the current tool set without manually polling for changes.
```python
@mcp.tool
def example_tool() -> str:
return "Hello!"
# These operations trigger notifications:
mcp.add_tool(example_tool) # Sends tools/list_changed notification
example_tool.disable() # Sends tools/list_changed notification
example_tool.enable() # Sends tools/list_changed notification
mcp.remove_tool("example_tool") # Sends tools/list_changed notification
```
Notifications are only sent when these operations occur within an active MCP request context (e.g., when called from within a tool or other MCP operation). Operations performed during server initialization do not trigger notifications.
Clients can handle these notifications using a [message handler](/clients/messages) to automatically refresh their tool lists or update their interfaces.
## MCP Context
Tools can access MCP features like logging, reading resources, or reporting progress through the `Context` object. To use it, add a parameter to your tool function with the type hint `Context`.

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,

View file

@ -0,0 +1,422 @@
from dataclasses import dataclass
import mcp.types
import pytest
from fastmcp import Client, FastMCP
from fastmcp.client.messages import MessageHandler
from fastmcp.server.context import Context
from fastmcp.tools.tool import Tool
@dataclass
class NotificationRecording:
"""Record of a notification that was received."""
method: str
notification: mcp.types.ServerNotification
class RecordingMessageHandler(MessageHandler):
"""A message handler that records all notifications."""
def __init__(self, name: str | None = None):
super().__init__()
self.notifications: list[NotificationRecording] = []
self.name = name
async def on_notification(self, message: mcp.types.ServerNotification) -> None:
"""Record all notifications."""
self.notifications.append(
NotificationRecording(method=message.root.method, notification=message)
)
def get_notifications(
self, method: str | None = None
) -> list[NotificationRecording]:
"""Get all recorded notifications, optionally filtered by method."""
if method is None:
return self.notifications
return [n for n in self.notifications if n.method == method]
def assert_notification_sent(self, method: str, times: int = 1) -> bool:
"""Assert that a notification was sent a specific number of times."""
notifications = self.get_notifications(method)
actual_times = len(notifications)
assert actual_times == times, (
f"Expected {times} notifications for {method}, "
f"but received {actual_times} notifications"
)
return True
def assert_notification_not_sent(self, method: str) -> bool:
"""Assert that a notification was not sent."""
notifications = self.get_notifications(method)
assert len(notifications) == 0, (
f"Expected no notifications for {method}, but received {len(notifications)}"
)
return True
def reset(self):
"""Clear all recorded notifications."""
self.notifications.clear()
@pytest.fixture
def recording_message_handler():
"""Fixture that provides a recording message handler instance."""
handler = RecordingMessageHandler(name="recording_message_handler")
yield handler
@pytest.fixture
def notification_test_server(recording_message_handler):
"""Create a server for testing notifications."""
mcp = FastMCP(name="NotificationTestServer")
# Create a target tool that can be enabled/disabled
def target_tool() -> str:
"""A tool that can be enabled/disabled."""
return "Target tool executed"
target_tool_obj = Tool.from_function(target_tool)
mcp.add_tool(target_tool_obj)
# Tool to enable the target tool
@mcp.tool
async def enable_target_tool(ctx: Context) -> str:
"""Enable the target tool."""
# Find and enable the target tool
try:
tool = await ctx.fastmcp.get_tool("target_tool")
tool.enable()
return "Target tool enabled"
except Exception:
return "Target tool not found"
# Tool to disable the target tool
@mcp.tool
async def disable_target_tool(ctx: Context) -> str:
"""Disable the target tool."""
# Find and disable the target tool
try:
tool = await ctx.fastmcp.get_tool("target_tool")
tool.disable()
return "Target tool disabled"
except Exception:
return "Target tool not found"
return mcp
class TestToolNotifications:
"""Test tool list changed notifications."""
async def test_tool_enable_sends_notification(
self,
notification_test_server: FastMCP,
recording_message_handler: RecordingMessageHandler,
):
"""Test that enabling a tool sends a tool list changed notification."""
async with Client(
notification_test_server, message_handler=recording_message_handler
) as client:
# Reset any initialization notifications
recording_message_handler.reset()
# Enable the target tool
result = await client.call_tool("enable_target_tool", {})
assert result[0].text == "Target tool enabled" # type: ignore[attr-defined]
# Check that notification was sent
recording_message_handler.assert_notification_sent(
"notifications/tools/list_changed", times=1
)
async def test_tool_disable_sends_notification(
self,
notification_test_server: FastMCP,
recording_message_handler: RecordingMessageHandler,
):
"""Test that disabling a tool sends a tool list changed notification."""
async with Client(
notification_test_server, message_handler=recording_message_handler
) as client:
# Reset any initialization notifications
recording_message_handler.reset()
# Disable the target tool
result = await client.call_tool("disable_target_tool", {})
assert result[0].text == "Target tool disabled" # type: ignore[attr-defined]
# Check that notification was sent
recording_message_handler.assert_notification_sent(
"notifications/tools/list_changed", times=1
)
async def test_multiple_tool_changes_deduplicates_notifications(
self,
notification_test_server: FastMCP,
recording_message_handler: RecordingMessageHandler,
):
"""Test that multiple rapid tool changes result in a single notification."""
async with Client(
notification_test_server, message_handler=recording_message_handler
) as client:
# Reset any initialization notifications
recording_message_handler.reset()
# Enable and disable multiple times in the same context
# This should result in deduplication
await client.call_tool("enable_target_tool", {})
await client.call_tool("disable_target_tool", {})
await client.call_tool("enable_target_tool", {})
# Should have 3 notifications (one per tool call context)
recording_message_handler.assert_notification_sent(
"notifications/tools/list_changed", times=3
)
@pytest.fixture
def resource_notification_test_server(recording_message_handler):
"""Create a server for testing resource notifications."""
mcp = FastMCP(name="ResourceNotificationTestServer")
# Create a target resource that can be enabled/disabled
@mcp.resource("resource://target")
def target_resource() -> str:
"""A resource that can be enabled/disabled."""
return "Target resource content"
# Tool to enable the target resource
@mcp.tool
async def enable_target_resource(ctx: Context) -> str:
"""Enable the target resource."""
try:
resource = await ctx.fastmcp.get_resource("resource://target")
resource.enable()
return "Target resource enabled"
except Exception:
return "Target resource not found"
# Tool to disable the target resource
@mcp.tool
async def disable_target_resource(ctx: Context) -> str:
"""Disable the target resource."""
try:
resource = await ctx.fastmcp.get_resource("resource://target")
resource.disable()
return "Target resource disabled"
except Exception:
return "Target resource not found"
return mcp
class TestResourceNotifications:
"""Test resource list changed notifications."""
async def test_resource_enable_sends_notification(
self,
resource_notification_test_server: FastMCP,
recording_message_handler: RecordingMessageHandler,
):
"""Test that enabling a resource sends a resource list changed notification."""
async with Client(
resource_notification_test_server, message_handler=recording_message_handler
) as client:
# Reset any initialization notifications
recording_message_handler.reset()
# Enable the target resource
result = await client.call_tool("enable_target_resource", {})
assert result[0].text == "Target resource enabled" # type: ignore[attr-defined]
# Check that notification was sent
recording_message_handler.assert_notification_sent(
"notifications/resources/list_changed", times=1
)
async def test_resource_disable_sends_notification(
self,
resource_notification_test_server: FastMCP,
recording_message_handler: RecordingMessageHandler,
):
"""Test that disabling a resource sends a resource list changed notification."""
async with Client(
resource_notification_test_server, message_handler=recording_message_handler
) as client:
# Reset any initialization notifications
recording_message_handler.reset()
# Disable the target resource
result = await client.call_tool("disable_target_resource", {})
assert result[0].text == "Target resource disabled" # type: ignore[attr-defined]
# Check that notification was sent
recording_message_handler.assert_notification_sent(
"notifications/resources/list_changed", times=1
)
@pytest.fixture
def prompt_notification_test_server(recording_message_handler):
"""Create a server for testing prompt notifications."""
mcp = FastMCP(name="PromptNotificationTestServer")
# Create a target prompt that can be enabled/disabled
@mcp.prompt
def target_prompt() -> str:
"""A prompt that can be enabled/disabled."""
return "Target prompt content"
# Tool to enable the target prompt
@mcp.tool
async def enable_target_prompt(ctx: Context) -> str:
"""Enable the target prompt."""
try:
prompt = await ctx.fastmcp.get_prompt("target_prompt")
prompt.enable()
return "Target prompt enabled"
except Exception:
return "Target prompt not found"
# Tool to disable the target prompt
@mcp.tool
async def disable_target_prompt(ctx: Context) -> str:
"""Disable the target prompt."""
try:
prompt = await ctx.fastmcp.get_prompt("target_prompt")
prompt.disable()
return "Target prompt disabled"
except Exception:
return "Target prompt not found"
return mcp
class TestPromptNotifications:
"""Test prompt list changed notifications."""
async def test_prompt_enable_sends_notification(
self,
prompt_notification_test_server: FastMCP,
recording_message_handler: RecordingMessageHandler,
):
"""Test that enabling a prompt sends a prompt list changed notification."""
async with Client(
prompt_notification_test_server, message_handler=recording_message_handler
) as client:
# Reset any initialization notifications
recording_message_handler.reset()
# Enable the target prompt
result = await client.call_tool("enable_target_prompt", {})
assert result[0].text == "Target prompt enabled" # type: ignore[attr-defined]
# Check that notification was sent
recording_message_handler.assert_notification_sent(
"notifications/prompts/list_changed", times=1
)
async def test_prompt_disable_sends_notification(
self,
prompt_notification_test_server: FastMCP,
recording_message_handler: RecordingMessageHandler,
):
"""Test that disabling a prompt sends a prompt list changed notification."""
async with Client(
prompt_notification_test_server, message_handler=recording_message_handler
) as client:
# Reset any initialization notifications
recording_message_handler.reset()
# Disable the target prompt
result = await client.call_tool("disable_target_prompt", {})
assert result[0].text == "Target prompt disabled" # type: ignore[attr-defined]
# Check that notification was sent
recording_message_handler.assert_notification_sent(
"notifications/prompts/list_changed", times=1
)
class TestMessageHandlerGeneral:
"""Test the message handler functionality in general."""
async def test_message_handler_receives_all_notifications(
self,
notification_test_server: FastMCP,
recording_message_handler: RecordingMessageHandler,
):
"""Test that the message handler receives all types of notifications."""
async with Client(
notification_test_server, message_handler=recording_message_handler
) as client:
recording_message_handler.reset()
# Trigger a tool notification
await client.call_tool("enable_target_tool", {})
# Verify the handler received the notification
all_notifications = recording_message_handler.get_notifications()
assert len(all_notifications) == 1
assert all_notifications[0].method == "notifications/tools/list_changed"
async def test_message_handler_notification_filtering(
self,
notification_test_server: FastMCP,
recording_message_handler: RecordingMessageHandler,
):
"""Test that notification filtering works correctly."""
async with Client(
notification_test_server, message_handler=recording_message_handler
) as client:
recording_message_handler.reset()
# Trigger tool notifications
await client.call_tool("enable_target_tool", {})
await client.call_tool("disable_target_tool", {})
# Test filtering
tool_notifications = recording_message_handler.get_notifications(
"notifications/tools/list_changed"
)
assert len(tool_notifications) == 2
# Test non-existent filter
resource_notifications = recording_message_handler.get_notifications(
"notifications/resources/list_changed"
)
assert len(resource_notifications) == 0
async def test_notification_structure(
self,
notification_test_server: FastMCP,
recording_message_handler: RecordingMessageHandler,
):
"""Test that notifications have the correct structure."""
async with Client(
notification_test_server, message_handler=recording_message_handler
) as client:
recording_message_handler.reset()
# Trigger a notification
await client.call_tool("enable_target_tool", {})
# Check notification structure
notifications = recording_message_handler.get_notifications(
"notifications/tools/list_changed"
)
assert len(notifications) == 1
notification = notifications[0]
assert isinstance(notification.notification, mcp.types.ServerNotification)
assert isinstance(
notification.notification.root, mcp.types.ToolListChangedNotification
)
assert (
notification.notification.root.method
== "notifications/tools/list_changed"
)

View file

@ -391,7 +391,7 @@ class TestContextHandling:
mcp = FastMCP()
context = Context(fastmcp=mcp)
with context:
async with context:
messages = await prompt.render(arguments={"x": 42})
assert len(messages) == 1
@ -411,7 +411,7 @@ class TestContextHandling:
mcp = FastMCP()
context = Context(fastmcp=mcp)
with context:
async with context:
messages = await prompt.render(
arguments={"x": 42},
)

View file

@ -670,7 +670,7 @@ class TestContextHandling:
mcp = FastMCP()
context = Context(fastmcp=mcp)
with context:
async with context:
resource = await template.create_resource(
"test://42",
{"x": 42},
@ -698,7 +698,7 @@ class TestContextHandling:
mcp = FastMCP()
context = Context(fastmcp=mcp)
with context:
async with context:
resource = await template.create_resource(
"test://42",
{"x": 42},

View file

@ -499,7 +499,7 @@ class TestCallTools:
mcp = FastMCP()
context = Context(fastmcp=mcp)
with context:
async with context:
result = await manager.call_tool(
"name_shrimp",
{
@ -639,7 +639,7 @@ class TestContextHandling:
mcp = FastMCP()
context = Context(fastmcp=mcp)
with context:
async with context:
result = await manager.call_tool("tool_with_context", {"x": 42})
assert result[0].text == "42" # type: ignore[attr-defined]
@ -657,7 +657,7 @@ class TestContextHandling:
mcp = FastMCP()
context = Context(fastmcp=mcp)
with context:
async with context:
result = await manager.call_tool("async_tool", {"x": 42})
assert result[0].text == "42" # type: ignore[attr-defined]
@ -675,7 +675,7 @@ class TestContextHandling:
mcp = FastMCP()
context = Context(fastmcp=mcp)
with context:
async with context:
result = await manager.call_tool("tool_with_context", {"x": 42})
assert result[0].text == "42" # type: ignore[attr-defined]
@ -722,7 +722,7 @@ class TestContextHandling:
mcp = FastMCP()
context = Context(fastmcp=mcp)
with context:
async with context:
with pytest.raises(
ToolError, match="Error calling tool 'tool_with_context'"
):