From 38036b1f425198fed7d87b51100a35696286ff95 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 24 Jun 2025 22:33:06 -0400 Subject: [PATCH] Add automatic MCP list change notifications and client message handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 1 + docs/clients/messages.mdx | 129 +++++++ docs/docs.json | 24 +- docs/servers/context.mdx | 19 + docs/servers/prompts.mdx | 22 +- docs/servers/resources.mdx | 24 +- docs/servers/tools.mdx | 22 ++ src/fastmcp/client/client.py | 4 +- src/fastmcp/client/logging.py | 3 +- src/fastmcp/client/messages.py | 122 +++++++ src/fastmcp/client/transports.py | 3 +- src/fastmcp/prompts/prompt.py | 16 + src/fastmcp/resources/resource.py | 16 + src/fastmcp/resources/template.py | 18 +- src/fastmcp/server/context.py | 80 +++- src/fastmcp/server/low_level.py | 35 ++ src/fastmcp/server/server.py | 77 +++- src/fastmcp/tools/tool.py | 16 + tests/client/test_notifications.py | 422 ++++++++++++++++++++++ tests/prompts/test_prompt_manager.py | 4 +- tests/resources/test_resource_template.py | 4 +- tests/tools/test_tool_manager.py | 10 +- 22 files changed, 1013 insertions(+), 58 deletions(-) create mode 100644 docs/clients/messages.mdx create mode 100644 src/fastmcp/client/messages.py create mode 100644 src/fastmcp/server/low_level.py create mode 100644 tests/client/test_notifications.py diff --git a/CLAUDE.md b/CLAUDE.md index 9d26dfeaf..18c7a4ffc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 \ No newline at end of file diff --git a/docs/clients/messages.mdx b/docs/clients/messages.mdx new file mode 100644 index 000000000..5c619fa09 --- /dev/null +++ b/docs/clients/messages.mdx @@ -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"; + + + +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: + + + + Called for ALL messages (requests and notifications) + + + + Called for requests that expect responses + + + + Called for notifications (fire-and-forget) + + + + Called when the server's tool list changes + + + + Called when the server's resource list changes + + + + Called when the server's prompt list changes + + + + Called for progress updates during long-running operations + + + + Called for log messages from the server + + + +## 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. \ No newline at end of file diff --git a/docs/docs.json b/docs/docs.json index f84e9b09b..e3526069e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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"] } ] }, diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx index 769c0b2d7..31f833c7c 100644 --- a/docs/servers/context.mdx +++ b/docs/servers/context.mdx @@ -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 + + + +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. diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx index f296cb74e..4db0be14c 100644 --- a/docs/servers/prompts.mdx +++ b/docs/servers/prompts.mdx @@ -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 + + +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 diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index 127f70805..647135d59 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -141,6 +141,7 @@ get_config.disable() get_config.enable() ``` + ### Accessing MCP Context @@ -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 + + + +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. diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index b136e9702..e403309a1 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -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 + + + +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`. diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 203fcea14..3432d6895 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -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, diff --git a/src/fastmcp/client/logging.py b/src/fastmcp/client/logging.py index d309a0674..f3c323b4e 100644 --- a/src/fastmcp/client/logging.py +++ b/src/fastmcp/client/logging.py @@ -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: diff --git a/src/fastmcp/client/messages.py b/src/fastmcp/client/messages.py new file mode 100644 index 000000000..9c0d862bf --- /dev/null +++ b/src/fastmcp/client/messages.py @@ -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 diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index b0571618d..23690e4cd 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -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 diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index b0c99e971..bdc7f95b1 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -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 = [ diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index b8174fd2a..dd04f8a3f 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -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], diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index ca4f3a3ee..00fc29863 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -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], diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 994052724..996fb6e64 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -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: diff --git a/src/fastmcp/server/low_level.py b/src/fastmcp/server/low_level.py new file mode 100644 index 000000000..620abe713 --- /dev/null +++ b/src/fastmcp/server/low_level.py @@ -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, + ) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 3a7685bfe..c00807475 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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, diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index fd40f0d2a..3aa484faa 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -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, diff --git a/tests/client/test_notifications.py b/tests/client/test_notifications.py new file mode 100644 index 000000000..0c833adf2 --- /dev/null +++ b/tests/client/test_notifications.py @@ -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" + ) diff --git a/tests/prompts/test_prompt_manager.py b/tests/prompts/test_prompt_manager.py index d69789972..5b359e56f 100644 --- a/tests/prompts/test_prompt_manager.py +++ b/tests/prompts/test_prompt_manager.py @@ -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}, ) diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index d8668c1ca..c6a7afcd7 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -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}, diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 6d6a19952..55a77de56 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -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'" ):