fastmcp/tests/client/test_notifications.py
2025-06-27 15:40:52 -04:00

422 lines
15 KiB
Python

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.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
)
@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.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
)
@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.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"
)