mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
422 lines
15 KiB
Python
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"
|
|
)
|