Spaces:
Running
Running
| 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 | |
| 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() | |
| def recording_message_handler(): | |
| """Fixture that provides a recording message handler instance.""" | |
| handler = RecordingMessageHandler(name="recording_message_handler") | |
| yield handler | |
| 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 | |
| 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 | |
| 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.data == "Target tool enabled" | |
| # 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.data == "Target tool disabled" | |
| # 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 | |
| ) | |
| 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 | |
| def target_resource() -> str: | |
| """A resource that can be enabled/disabled.""" | |
| return "Target resource content" | |
| # Tool to enable the target resource | |
| 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 | |
| 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.data == "Target resource enabled" | |
| # 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.data == "Target resource disabled" | |
| # Check that notification was sent | |
| recording_message_handler.assert_notification_sent( | |
| "notifications/resources/list_changed", times=1 | |
| ) | |
| 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 | |
| def target_prompt() -> str: | |
| """A prompt that can be enabled/disabled.""" | |
| return "Target prompt content" | |
| # Tool to enable the target prompt | |
| 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 | |
| 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.data == "Target prompt enabled" | |
| # 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.data == "Target prompt disabled" | |
| # 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" | |
| ) | |