mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
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:
parent
c8da2432c3
commit
38036b1f42
22 changed files with 1013 additions and 58 deletions
|
|
@ -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
129
docs/clients/messages.mdx
Normal 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.
|
||||
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
122
src/fastmcp/client/messages.py
Normal file
122
src/fastmcp/client/messages.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
35
src/fastmcp/server/low_level.py
Normal file
35
src/fastmcp/server/low_level.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
422
tests/client/test_notifications.py
Normal file
422
tests/client/test_notifications.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue