Jeremiah Lowin commited on
Commit
4dbbe51
·
unverified ·
2 Parent(s): 06e8678fd3fdc0

Merge pull request #939 from jlowin/notifications

Browse files

Add automatic MCP list change notifications and client message handling

CLAUDE.md CHANGED
@@ -34,3 +34,4 @@ async with Client(transport=StreamableHttpTransport(server_url)) as client:
34
  - You must always run pre-commit if you open a PR, because it is run as part of a required check.
35
  - When opening PRs, apply labels appropriately for bugs/breaking changes/enhancements/features. Generally, improvements are enhancements (not features) unless told otherwise.
36
  - NEVER modify files in docs/python-sdk/**, as they are auto-generated.
 
 
34
  - You must always run pre-commit if you open a PR, because it is run as part of a required check.
35
  - When opening PRs, apply labels appropriately for bugs/breaking changes/enhancements/features. Generally, improvements are enhancements (not features) unless told otherwise.
36
  - NEVER modify files in docs/python-sdk/**, as they are auto-generated.
37
+ - Use # type: ignore[attr-defined] in unit tests when accessing an MCP result of indeterminate type instead of asserting its type
docs/clients/messages.mdx ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Message Handling
3
+ sidebarTitle: Messages
4
+ description: Handle MCP messages, requests, and notifications with custom message handlers.
5
+ icon: envelope
6
+ ---
7
+
8
+ import { VersionBadge } from "/snippets/version-badge.mdx";
9
+
10
+ <VersionBadge version="2.9.0" />
11
+
12
+ 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.
13
+
14
+ ## Function-Based Handler
15
+
16
+ The simplest way to handle messages is with a function that receives all messages:
17
+
18
+ ```python
19
+ from fastmcp import Client
20
+
21
+ async def message_handler(message):
22
+ """Handle all MCP messages from the server."""
23
+ if hasattr(message, 'root'):
24
+ method = message.root.method
25
+ print(f"Received: {method}")
26
+
27
+ # Handle specific notifications
28
+ if method == "notifications/tools/list_changed":
29
+ print("Tools have changed - might want to refresh tool cache")
30
+ elif method == "notifications/resources/list_changed":
31
+ print("Resources have changed")
32
+
33
+ client = Client(
34
+ "my_mcp_server.py",
35
+ message_handler=message_handler,
36
+ )
37
+ ```
38
+
39
+ ## Message Handler Class
40
+
41
+ For fine-grained targeting, FastMCP provides a `MessageHandler` class you can subclass to take advantage of specific hooks:
42
+
43
+ ```python
44
+ from fastmcp import Client
45
+ from fastmcp.client.messages import MessageHandler
46
+ import mcp.types
47
+
48
+ class MyMessageHandler(MessageHandler):
49
+ async def on_tool_list_changed(
50
+ self, notification: mcp.types.ToolListChangedNotification
51
+ ) -> None:
52
+ """Handle tool list changes specifically."""
53
+ print("Tool list changed - refreshing available tools")
54
+
55
+ client = Client(
56
+ "my_mcp_server.py",
57
+ message_handler=MyMessageHandler(),
58
+ )
59
+ ```
60
+
61
+ ### Available Handler Methods
62
+
63
+ All handler methods receive a single argument - the specific message type:
64
+
65
+ <Card icon="code" title="Message Handler Methods">
66
+ <ResponseField name="on_message(message)" type="Any MCP message">
67
+ Called for ALL messages (requests and notifications)
68
+ </ResponseField>
69
+
70
+ <ResponseField name="on_request(request)" type="mcp.types.ClientRequest">
71
+ Called for requests that expect responses
72
+ </ResponseField>
73
+
74
+ <ResponseField name="on_notification(notification)" type="mcp.types.ServerNotification">
75
+ Called for notifications (fire-and-forget)
76
+ </ResponseField>
77
+
78
+ <ResponseField name="on_tool_list_changed(notification)" type="mcp.types.ToolListChangedNotification">
79
+ Called when the server's tool list changes
80
+ </ResponseField>
81
+
82
+ <ResponseField name="on_resource_list_changed(notification)" type="mcp.types.ResourceListChangedNotification">
83
+ Called when the server's resource list changes
84
+ </ResponseField>
85
+
86
+ <ResponseField name="on_prompt_list_changed(notification)" type="mcp.types.PromptListChangedNotification">
87
+ Called when the server's prompt list changes
88
+ </ResponseField>
89
+
90
+ <ResponseField name="on_progress(notification)" type="mcp.types.ProgressNotification">
91
+ Called for progress updates during long-running operations
92
+ </ResponseField>
93
+
94
+ <ResponseField name="on_logging_message(notification)" type="mcp.types.LoggingMessageNotification">
95
+ Called for log messages from the server
96
+ </ResponseField>
97
+ </Card>
98
+
99
+ ## Example: Handling Tool Changes
100
+
101
+ Here's a practical example of handling tool list changes:
102
+
103
+ ```python
104
+ from fastmcp.client.messages import MessageHandler
105
+ import mcp.types
106
+
107
+ class ToolCacheHandler(MessageHandler):
108
+ def __init__(self):
109
+ self.cached_tools = []
110
+
111
+ async def on_tool_list_changed(
112
+ self, notification: mcp.types.ToolListChangedNotification
113
+ ) -> None:
114
+ """Clear tool cache when tools change."""
115
+ print("Tools changed - clearing cache")
116
+ self.cached_tools = [] # Force refresh on next access
117
+
118
+ client = Client("server.py", message_handler=ToolCacheHandler())
119
+ ```
120
+
121
+ ## Handling Requests
122
+
123
+ While the message handler receives server-initiated requests, for most use cases you should use the dedicated callback parameters instead:
124
+
125
+ - **Sampling requests**: Use [`sampling_handler`](/clients/sampling)
126
+ - **Progress requests**: Use [`progress_handler`](/clients/progress)
127
+ - **Log requests**: Use [`log_handler`](/clients/logging)
128
+
129
+ The message handler is primarily for monitoring and handling notifications rather than responding to requests.
docs/docs.json CHANGED
@@ -76,9 +76,7 @@
76
  {
77
  "group": "Authentication",
78
  "icon": "shield-check",
79
- "pages": [
80
- "servers/auth/bearer"
81
- ]
82
  },
83
  "servers/middleware",
84
  "servers/openapi",
@@ -87,10 +85,7 @@
87
  {
88
  "group": "Deployment",
89
  "icon": "upload",
90
- "pages": [
91
- "deployment/running-server",
92
- "deployment/asgi"
93
- ]
94
  }
95
  ]
96
  },
@@ -114,6 +109,7 @@
114
  "clients/logging",
115
  "clients/progress",
116
  "clients/sampling",
 
117
  "clients/roots"
118
  ]
119
  },
@@ -121,10 +117,7 @@
121
  {
122
  "group": "Authentication",
123
  "icon": "user-shield",
124
- "pages": [
125
- "clients/auth/oauth",
126
- "clients/auth/bearer"
127
- ]
128
  }
129
  ]
130
  },
@@ -163,17 +156,12 @@
163
  },
164
  {
165
  "anchor": "What's New",
166
- "pages": [
167
- "updates",
168
- "changelog"
169
- ]
170
  },
171
  {
172
  "anchor": "Community",
173
  "icon": "users",
174
- "pages": [
175
- "community/showcase"
176
- ]
177
  }
178
  ]
179
  },
 
76
  {
77
  "group": "Authentication",
78
  "icon": "shield-check",
79
+ "pages": ["servers/auth/bearer"]
 
 
80
  },
81
  "servers/middleware",
82
  "servers/openapi",
 
85
  {
86
  "group": "Deployment",
87
  "icon": "upload",
88
+ "pages": ["deployment/running-server", "deployment/asgi"]
 
 
 
89
  }
90
  ]
91
  },
 
109
  "clients/logging",
110
  "clients/progress",
111
  "clients/sampling",
112
+ "clients/messages",
113
  "clients/roots"
114
  ]
115
  },
 
117
  {
118
  "group": "Authentication",
119
  "icon": "user-shield",
120
+ "pages": ["clients/auth/oauth", "clients/auth/bearer"]
 
 
 
121
  }
122
  ]
123
  },
 
156
  },
157
  {
158
  "anchor": "What's New",
159
+ "pages": ["updates", "changelog"]
 
 
 
160
  },
161
  {
162
  "anchor": "Community",
163
  "icon": "users",
164
+ "pages": ["community/showcase"]
 
 
165
  }
166
  ]
167
  },
docs/servers/context.mdx CHANGED
@@ -275,6 +275,25 @@ async def generate_example(concept: str, ctx: Context) -> str:
275
 
276
  See [Client Sampling](/clients/client#llm-sampling) for more details on how clients handle these requests.
277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  ### Request Information
279
 
280
  Access metadata about the current request and client.
 
275
 
276
  See [Client Sampling](/clients/client#llm-sampling) for more details on how clients handle these requests.
277
 
278
+ ### Component Changes
279
+
280
+ <VersionBadge version="2.9.1" />
281
+
282
+ 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:
283
+
284
+ ```python
285
+ @mcp.tool
286
+ async def custom_tool_management(ctx: Context) -> str:
287
+ """Example of manual notification after custom tool changes."""
288
+ # After making custom changes to tools
289
+ await ctx.send_tool_list_changed()
290
+ await ctx.send_resource_list_changed()
291
+ await ctx.send_prompt_list_changed()
292
+ return "Notifications sent"
293
+ ```
294
+
295
+ These methods are primarily used internally by FastMCP's automatic notification system and most users will not need to invoke them directly.
296
+
297
  ### Request Information
298
 
299
  Access metadata about the current request and client.
docs/servers/prompts.mdx CHANGED
@@ -237,7 +237,8 @@ def seasonal_prompt(): return "Happy Holidays!"
237
  seasonal_prompt.disable()
238
  seasonal_prompt.enable()
239
  ```
240
- ### Asynchronous Prompts
 
241
 
242
  FastMCP seamlessly supports both standard (`def`) and asynchronous (`async def`) functions as prompts.
243
 
@@ -280,7 +281,26 @@ async def generate_report_request(report_type: str, ctx: Context) -> str:
280
 
281
  For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
282
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
 
 
284
 
285
  ## Server Behavior
286
 
 
237
  seasonal_prompt.disable()
238
  seasonal_prompt.enable()
239
  ```
240
+
241
+ ### Async Prompts
242
 
243
  FastMCP seamlessly supports both standard (`def`) and asynchronous (`async def`) functions as prompts.
244
 
 
281
 
282
  For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
283
 
284
+ ### Notifications
285
+
286
+ <VersionBadge version="2.9.1" />
287
+
288
+ 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.
289
+
290
+ ```python
291
+ @mcp.prompt
292
+ def example_prompt() -> str:
293
+ return "Hello!"
294
+
295
+ # These operations trigger notifications:
296
+ mcp.add_prompt(example_prompt) # Sends prompts/list_changed notification
297
+ example_prompt.disable() # Sends prompts/list_changed notification
298
+ example_prompt.enable() # Sends prompts/list_changed notification
299
+ ```
300
+
301
+ 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.
302
 
303
+ Clients can handle these notifications using a [message handler](/clients/messages) to automatically refresh their prompt lists or update their interfaces.
304
 
305
  ## Server Behavior
306
 
docs/servers/resources.mdx CHANGED
@@ -141,6 +141,7 @@ get_config.disable()
141
  get_config.enable()
142
  ```
143
 
 
144
  ### Accessing MCP Context
145
 
146
  <VersionBadge version="2.2.5" />
@@ -172,7 +173,7 @@ async def get_details(name: str, ctx: Context) -> dict:
172
  For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
173
 
174
 
175
- ### Asynchronous Resources
176
 
177
  Use `async def` for resource functions that perform I/O operations (e.g., reading from a database or network) to avoid blocking the server.
178
 
@@ -278,6 +279,27 @@ mcp.add_resource(special_resource, key="internal://data-v2") # Will be stored a
278
 
279
  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.
280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  ## Resource Templates
282
 
283
  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.
 
141
  get_config.enable()
142
  ```
143
 
144
+
145
  ### Accessing MCP Context
146
 
147
  <VersionBadge version="2.2.5" />
 
173
  For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
174
 
175
 
176
+ ### Async Resources
177
 
178
  Use `async def` for resource functions that perform I/O operations (e.g., reading from a database or network) to avoid blocking the server.
179
 
 
279
 
280
  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.
281
 
282
+ ### Notifications
283
+
284
+ <VersionBadge version="2.9.1" />
285
+
286
+ 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.
287
+
288
+ ```python
289
+ @mcp.resource("data://example")
290
+ def example_resource() -> str:
291
+ return "Hello!"
292
+
293
+ # These operations trigger notifications:
294
+ mcp.add_resource(example_resource) # Sends resources/list_changed notification
295
+ example_resource.disable() # Sends resources/list_changed notification
296
+ example_resource.enable() # Sends resources/list_changed notification
297
+ ```
298
+
299
+ 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.
300
+
301
+ Clients can handle these notifications using a [message handler](/clients/messages) to automatically refresh their resource lists or update their interfaces.
302
+
303
  ## Resource Templates
304
 
305
  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.
docs/servers/tools.mdx CHANGED
@@ -415,6 +415,28 @@ FastMCP supports these standard annotations:
415
 
416
  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.
417
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  ## MCP Context
419
 
420
  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`.
 
415
 
416
  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.
417
 
418
+ ### Notifications
419
+
420
+ <VersionBadge version="2.9.1" />
421
+
422
+ 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.
423
+
424
+ ```python
425
+ @mcp.tool
426
+ def example_tool() -> str:
427
+ return "Hello!"
428
+
429
+ # These operations trigger notifications:
430
+ mcp.add_tool(example_tool) # Sends tools/list_changed notification
431
+ example_tool.disable() # Sends tools/list_changed notification
432
+ example_tool.enable() # Sends tools/list_changed notification
433
+ mcp.remove_tool("example_tool") # Sends tools/list_changed notification
434
+ ```
435
+
436
+ 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.
437
+
438
+ Clients can handle these notifications using a [message handler](/clients/messages) to automatically refresh their tool lists or update their interfaces.
439
+
440
  ## MCP Context
441
 
442
  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`.
src/fastmcp/client/client.py CHANGED
@@ -15,10 +15,10 @@ from pydantic import AnyUrl
15
  import fastmcp
16
  from fastmcp.client.logging import (
17
  LogHandler,
18
- MessageHandler,
19
  create_log_callback,
20
  default_log_handler,
21
  )
 
22
  from fastmcp.client.progress import ProgressHandler, default_progress_handler
23
  from fastmcp.client.roots import (
24
  RootsHandler,
@@ -143,7 +143,7 @@ class Client(Generic[ClientTransportT]):
143
  roots: RootsList | RootsHandler | None = None,
144
  sampling_handler: SamplingHandler | None = None,
145
  log_handler: LogHandler | None = None,
146
- message_handler: MessageHandler | None = None,
147
  progress_handler: ProgressHandler | None = None,
148
  timeout: datetime.timedelta | float | int | None = None,
149
  init_timeout: datetime.timedelta | float | int | None = None,
 
15
  import fastmcp
16
  from fastmcp.client.logging import (
17
  LogHandler,
 
18
  create_log_callback,
19
  default_log_handler,
20
  )
21
+ from fastmcp.client.messages import MessageHandler, MessageHandlerT
22
  from fastmcp.client.progress import ProgressHandler, default_progress_handler
23
  from fastmcp.client.roots import (
24
  RootsHandler,
 
143
  roots: RootsList | RootsHandler | None = None,
144
  sampling_handler: SamplingHandler | None = None,
145
  log_handler: LogHandler | None = None,
146
+ message_handler: MessageHandlerT | MessageHandler | None = None,
147
  progress_handler: ProgressHandler | None = None,
148
  timeout: datetime.timedelta | float | int | None = None,
149
  init_timeout: datetime.timedelta | float | int | None = None,
src/fastmcp/client/logging.py CHANGED
@@ -1,7 +1,7 @@
1
  from collections.abc import Awaitable, Callable
2
  from typing import TypeAlias
3
 
4
- from mcp.client.session import LoggingFnT, MessageHandlerFnT
5
  from mcp.types import LoggingMessageNotificationParams
6
 
7
  from fastmcp.utilities.logging import get_logger
@@ -10,7 +10,6 @@ logger = get_logger(__name__)
10
 
11
  LogMessage: TypeAlias = LoggingMessageNotificationParams
12
  LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]]
13
- MessageHandler: TypeAlias = MessageHandlerFnT
14
 
15
 
16
  async def default_log_handler(message: LogMessage) -> None:
 
1
  from collections.abc import Awaitable, Callable
2
  from typing import TypeAlias
3
 
4
+ from mcp.client.session import LoggingFnT
5
  from mcp.types import LoggingMessageNotificationParams
6
 
7
  from fastmcp.utilities.logging import get_logger
 
10
 
11
  LogMessage: TypeAlias = LoggingMessageNotificationParams
12
  LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]]
 
13
 
14
 
15
  async def default_log_handler(message: LogMessage) -> None:
src/fastmcp/client/messages.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TypeAlias
2
+
3
+ import mcp.types
4
+ from mcp.client.session import MessageHandlerFnT
5
+ from mcp.shared.session import RequestResponder
6
+
7
+ Message: TypeAlias = (
8
+ RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
9
+ | mcp.types.ServerNotification
10
+ | Exception
11
+ )
12
+
13
+ MessageHandlerT: TypeAlias = MessageHandlerFnT
14
+
15
+
16
+ class MessageHandler:
17
+ """
18
+ This class is used to handle MCP messages sent to the client. It is used to handle all messages,
19
+ requests, notifications, and exceptions. Users can override any of the hooks
20
+ """
21
+
22
+ async def __call__(
23
+ self,
24
+ message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
25
+ | mcp.types.ServerNotification
26
+ | Exception,
27
+ ) -> None:
28
+ return await self.dispatch(message)
29
+
30
+ async def dispatch(self, message: Message) -> None:
31
+ # handle all messages
32
+ await self.on_message(message)
33
+
34
+ match message:
35
+ # requests
36
+ case RequestResponder():
37
+ # handle all requests
38
+ await self.on_request(message)
39
+
40
+ # handle specific requests
41
+ match message.request.root:
42
+ case mcp.types.PingRequest():
43
+ await self.on_ping(message.request.root)
44
+ case mcp.types.ListRootsRequest():
45
+ await self.on_list_roots(message.request.root)
46
+ case mcp.types.CreateMessageRequest():
47
+ await self.on_create_message(message.request.root)
48
+
49
+ # notifications
50
+ case mcp.types.ServerNotification():
51
+ # handle all notifications
52
+ await self.on_notification(message)
53
+
54
+ # handle specific notifications
55
+ match message.root:
56
+ case mcp.types.CancelledNotification():
57
+ await self.on_cancelled(message.root)
58
+ case mcp.types.ProgressNotification():
59
+ await self.on_progress(message.root)
60
+ case mcp.types.LoggingMessageNotification():
61
+ await self.on_logging_message(message.root)
62
+ case mcp.types.ToolListChangedNotification():
63
+ await self.on_tool_list_changed(message.root)
64
+ case mcp.types.ResourceListChangedNotification():
65
+ await self.on_resource_list_changed(message.root)
66
+ case mcp.types.PromptListChangedNotification():
67
+ await self.on_prompt_list_changed(message.root)
68
+ case mcp.types.ResourceUpdatedNotification():
69
+ await self.on_resource_updated(message.root)
70
+
71
+ case Exception():
72
+ await self.on_exception(message)
73
+
74
+ async def on_message(self, message: Message) -> None:
75
+ pass
76
+
77
+ async def on_request(
78
+ self, message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
79
+ ) -> None:
80
+ pass
81
+
82
+ async def on_ping(self, message: mcp.types.PingRequest) -> None:
83
+ pass
84
+
85
+ async def on_list_roots(self, message: mcp.types.ListRootsRequest) -> None:
86
+ pass
87
+
88
+ async def on_create_message(self, message: mcp.types.CreateMessageRequest) -> None:
89
+ pass
90
+
91
+ async def on_notification(self, message: mcp.types.ServerNotification) -> None:
92
+ pass
93
+
94
+ async def on_exception(self, message: Exception) -> None:
95
+ pass
96
+
97
+ async def on_progress(self, message: mcp.types.ProgressNotification) -> None:
98
+ pass
99
+
100
+ async def on_logging_message(
101
+ self, message: mcp.types.LoggingMessageNotification
102
+ ) -> None:
103
+ pass
104
+
105
+ async def on_tool_list_changed(
106
+ self, message: mcp.types.ToolListChangedNotification
107
+ ) -> None:
108
+ pass
109
+
110
+ async def on_resource_list_changed(
111
+ self, message: mcp.types.ResourceListChangedNotification
112
+ ) -> None:
113
+ pass
114
+
115
+ async def on_prompt_list_changed(
116
+ self, message: mcp.types.PromptListChangedNotification
117
+ ) -> None:
118
+ pass
119
+
120
+ async def on_resource_updated(
121
+ self, message: mcp.types.ResourceUpdatedNotification
122
+ ) -> None:
123
+ pass
124
+
125
+ async def on_cancelled(self, message: mcp.types.CancelledNotification) -> None:
126
+ pass
src/fastmcp/prompts/prompt.py CHANGED
@@ -70,6 +70,22 @@ class Prompt(FastMCPComponent, ABC):
70
  default=None, description="Arguments that can be passed to the prompt"
71
  )
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  def to_mcp_prompt(self, **overrides: Any) -> MCPPrompt:
74
  """Convert the prompt to an MCP prompt."""
75
  arguments = [
 
70
  default=None, description="Arguments that can be passed to the prompt"
71
  )
72
 
73
+ def enable(self) -> None:
74
+ super().enable()
75
+ try:
76
+ context = get_context()
77
+ context._queue_prompt_list_changed() # type: ignore[private-use]
78
+ except RuntimeError:
79
+ pass # No context available
80
+
81
+ def disable(self) -> None:
82
+ super().disable()
83
+ try:
84
+ context = get_context()
85
+ context._queue_prompt_list_changed() # type: ignore[private-use]
86
+ except RuntimeError:
87
+ pass # No context available
88
+
89
  def to_mcp_prompt(self, **overrides: Any) -> MCPPrompt:
90
  """Convert the prompt to an MCP prompt."""
91
  arguments = [
src/fastmcp/resources/resource.py CHANGED
@@ -44,6 +44,22 @@ class Resource(FastMCPComponent, abc.ABC):
44
  pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
45
  )
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  @staticmethod
48
  def from_function(
49
  fn: Callable[[], Any],
 
44
  pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
45
  )
46
 
47
+ def enable(self) -> None:
48
+ super().enable()
49
+ try:
50
+ context = get_context()
51
+ context._queue_resource_list_changed() # type: ignore[private-use]
52
+ except RuntimeError:
53
+ pass # No context available
54
+
55
+ def disable(self) -> None:
56
+ super().disable()
57
+ try:
58
+ context = get_context()
59
+ context._queue_resource_list_changed() # type: ignore[private-use]
60
+ except RuntimeError:
61
+ pass # No context available
62
+
63
  @staticmethod
64
  def from_function(
65
  fn: Callable[[], Any],
src/fastmcp/resources/template.py CHANGED
@@ -15,7 +15,7 @@ from pydantic import (
15
  validate_call,
16
  )
17
 
18
- from fastmcp.resources.types import Resource
19
  from fastmcp.server.dependencies import get_context
20
  from fastmcp.utilities.components import FastMCPComponent
21
  from fastmcp.utilities.json_schema import compress_schema
@@ -65,6 +65,22 @@ class ResourceTemplate(FastMCPComponent):
65
  def __repr__(self) -> str:
66
  return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  @staticmethod
69
  def from_function(
70
  fn: Callable[..., Any],
 
15
  validate_call,
16
  )
17
 
18
+ from fastmcp.resources.resource import Resource
19
  from fastmcp.server.dependencies import get_context
20
  from fastmcp.utilities.components import FastMCPComponent
21
  from fastmcp.utilities.json_schema import compress_schema
 
65
  def __repr__(self) -> str:
66
  return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
67
 
68
+ def enable(self) -> None:
69
+ super().enable()
70
+ try:
71
+ context = get_context()
72
+ context._queue_resource_list_changed() # type: ignore[private-use]
73
+ except RuntimeError:
74
+ pass # No context available
75
+
76
+ def disable(self) -> None:
77
+ super().disable()
78
+ try:
79
+ context = get_context()
80
+ context._queue_resource_list_changed() # type: ignore[private-use]
81
+ except RuntimeError:
82
+ pass # No context available
83
+
84
  @staticmethod
85
  def from_function(
86
  fn: Callable[..., Any],
src/fastmcp/server/context.py CHANGED
@@ -1,12 +1,13 @@
1
  from __future__ import annotations as _annotations
2
 
 
3
  import warnings
4
  from collections.abc import Generator
5
  from contextlib import contextmanager
6
  from contextvars import ContextVar, Token
7
  from dataclasses import dataclass
8
 
9
- from mcp import LoggingLevel
10
  from mcp.server.lowlevel.helper_types import ReadResourceContents
11
  from mcp.server.lowlevel.server import request_ctx
12
  from mcp.shared.context import RequestContext
@@ -30,6 +31,7 @@ from fastmcp.utilities.types import MCPContent
30
  logger = get_logger(__name__)
31
 
32
  _current_context: ContextVar[Context | None] = ContextVar("context", default=None)
 
33
 
34
 
35
  @contextmanager
@@ -80,16 +82,20 @@ class Context:
80
  def __init__(self, fastmcp: FastMCP):
81
  self.fastmcp = fastmcp
82
  self._tokens: list[Token] = []
 
83
 
84
- def __enter__(self) -> Context:
85
  """Enter the context manager and set this context as the current context."""
86
  # Always set this context and save the token
87
  token = _current_context.set(self)
88
  self._tokens.append(token)
89
  return self
90
 
91
- def __exit__(self, exc_type, exc_val, exc_tb) -> None:
92
  """Exit the context manager and reset the most recent token."""
 
 
 
93
  if self._tokens:
94
  token = self._tokens.pop()
95
  _current_context.reset(token)
@@ -124,7 +130,7 @@ class Context:
124
  if progress_token is None:
125
  return
126
 
127
- await self.request_context.session.send_progress_notification(
128
  progress_token=progress_token,
129
  progress=progress,
130
  total=total,
@@ -160,7 +166,7 @@ class Context:
160
  """
161
  if level is None:
162
  level = "info"
163
- await self.request_context.session.send_log_message(
164
  level=level, data=message, logger=logger_name
165
  )
166
 
@@ -210,7 +216,7 @@ class Context:
210
  return None
211
 
212
  @property
213
- def session(self):
214
  """Access to the underlying session for advanced usage."""
215
  return self.request_context.session
216
 
@@ -233,9 +239,21 @@ class Context:
233
 
234
  async def list_roots(self) -> list[Root]:
235
  """List the roots available to the server, as indicated by the client."""
236
- result = await self.request_context.session.list_roots()
237
  return result.roots
238
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  async def sample(
240
  self,
241
  messages: str | list[str | SamplingMessage],
@@ -269,7 +287,7 @@ class Context:
269
  for m in messages
270
  ]
271
 
272
- result: CreateMessageResult = await self.request_context.session.create_message(
273
  messages=sampling_messages,
274
  system_prompt=system_prompt,
275
  temperature=temperature,
@@ -294,6 +312,52 @@ class Context:
294
 
295
  return fastmcp.server.dependencies.get_http_request()
296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  def _parse_model_preferences(
298
  self, model_preferences: ModelPreferences | str | list[str] | None
299
  ) -> ModelPreferences | None:
 
1
  from __future__ import annotations as _annotations
2
 
3
+ import asyncio
4
  import warnings
5
  from collections.abc import Generator
6
  from contextlib import contextmanager
7
  from contextvars import ContextVar, Token
8
  from dataclasses import dataclass
9
 
10
+ from mcp import LoggingLevel, ServerSession
11
  from mcp.server.lowlevel.helper_types import ReadResourceContents
12
  from mcp.server.lowlevel.server import request_ctx
13
  from mcp.shared.context import RequestContext
 
31
  logger = get_logger(__name__)
32
 
33
  _current_context: ContextVar[Context | None] = ContextVar("context", default=None)
34
+ _flush_lock = asyncio.Lock()
35
 
36
 
37
  @contextmanager
 
82
  def __init__(self, fastmcp: FastMCP):
83
  self.fastmcp = fastmcp
84
  self._tokens: list[Token] = []
85
+ self._notification_queue: set[str] = set() # Dedupe notifications
86
 
87
+ async def __aenter__(self) -> Context:
88
  """Enter the context manager and set this context as the current context."""
89
  # Always set this context and save the token
90
  token = _current_context.set(self)
91
  self._tokens.append(token)
92
  return self
93
 
94
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
95
  """Exit the context manager and reset the most recent token."""
96
+ # Flush any remaining notifications before exiting
97
+ await self._flush_notifications()
98
+
99
  if self._tokens:
100
  token = self._tokens.pop()
101
  _current_context.reset(token)
 
130
  if progress_token is None:
131
  return
132
 
133
+ await self.session.send_progress_notification(
134
  progress_token=progress_token,
135
  progress=progress,
136
  total=total,
 
166
  """
167
  if level is None:
168
  level = "info"
169
+ await self.session.send_log_message(
170
  level=level, data=message, logger=logger_name
171
  )
172
 
 
216
  return None
217
 
218
  @property
219
+ def session(self) -> ServerSession:
220
  """Access to the underlying session for advanced usage."""
221
  return self.request_context.session
222
 
 
239
 
240
  async def list_roots(self) -> list[Root]:
241
  """List the roots available to the server, as indicated by the client."""
242
+ result = await self.session.list_roots()
243
  return result.roots
244
 
245
+ async def send_tool_list_changed(self) -> None:
246
+ """Send a tool list changed notification to the client."""
247
+ await self.session.send_tool_list_changed()
248
+
249
+ async def send_resource_list_changed(self) -> None:
250
+ """Send a resource list changed notification to the client."""
251
+ await self.session.send_resource_list_changed()
252
+
253
+ async def send_prompt_list_changed(self) -> None:
254
+ """Send a prompt list changed notification to the client."""
255
+ await self.session.send_prompt_list_changed()
256
+
257
  async def sample(
258
  self,
259
  messages: str | list[str | SamplingMessage],
 
287
  for m in messages
288
  ]
289
 
290
+ result: CreateMessageResult = await self.session.create_message(
291
  messages=sampling_messages,
292
  system_prompt=system_prompt,
293
  temperature=temperature,
 
312
 
313
  return fastmcp.server.dependencies.get_http_request()
314
 
315
+ def _queue_tool_list_changed(self) -> None:
316
+ """Queue a tool list changed notification."""
317
+ self._notification_queue.add("notifications/tools/list_changed")
318
+ self._try_flush_notifications()
319
+
320
+ def _queue_resource_list_changed(self) -> None:
321
+ """Queue a resource list changed notification."""
322
+ self._notification_queue.add("notifications/resources/list_changed")
323
+ self._try_flush_notifications()
324
+
325
+ def _queue_prompt_list_changed(self) -> None:
326
+ """Queue a prompt list changed notification."""
327
+ self._notification_queue.add("notifications/prompts/list_changed")
328
+ self._try_flush_notifications()
329
+
330
+ def _try_flush_notifications(self) -> None:
331
+ """Synchronous method that attempts to flush notifications if we're in an async context."""
332
+ try:
333
+ # Check if we're in an async context
334
+ loop = asyncio.get_running_loop()
335
+ if loop and not loop.is_running():
336
+ return
337
+ # Schedule flush as a task (fire-and-forget)
338
+ asyncio.create_task(self._flush_notifications())
339
+ except RuntimeError:
340
+ # No event loop - will flush later
341
+ pass
342
+
343
+ async def _flush_notifications(self) -> None:
344
+ """Send all queued notifications."""
345
+ async with _flush_lock:
346
+ if not self._notification_queue:
347
+ return
348
+
349
+ try:
350
+ if "notifications/tools/list_changed" in self._notification_queue:
351
+ await self.session.send_tool_list_changed()
352
+ if "notifications/resources/list_changed" in self._notification_queue:
353
+ await self.session.send_resource_list_changed()
354
+ if "notifications/prompts/list_changed" in self._notification_queue:
355
+ await self.session.send_prompt_list_changed()
356
+ self._notification_queue.clear()
357
+ except Exception:
358
+ # Don't let notification failures break the request
359
+ pass
360
+
361
  def _parse_model_preferences(
362
  self, model_preferences: ModelPreferences | str | list[str] | None
363
  ) -> ModelPreferences | None:
src/fastmcp/server/low_level.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from mcp.server.lowlevel.server import (
4
+ LifespanResultT,
5
+ NotificationOptions,
6
+ RequestT,
7
+ Server,
8
+ )
9
+ from mcp.server.models import InitializationOptions
10
+
11
+
12
+ class LowLevelServer(Server[LifespanResultT, RequestT]):
13
+ def __init__(self, *args, **kwargs):
14
+ super().__init__(*args, **kwargs)
15
+ # FastMCP servers support notifications for all components
16
+ self.notification_options = NotificationOptions(
17
+ prompts_changed=True,
18
+ resources_changed=True,
19
+ tools_changed=True,
20
+ )
21
+
22
+ def create_initialization_options(
23
+ self,
24
+ notification_options: NotificationOptions | None = None,
25
+ experimental_capabilities: dict[str, dict[str, Any]] | None = None,
26
+ **kwargs: Any,
27
+ ) -> InitializationOptions:
28
+ # ensure we use the FastMCP notification options
29
+ if notification_options is None:
30
+ notification_options = self.notification_options
31
+ return super().create_initialization_options(
32
+ notification_options=notification_options,
33
+ experimental_capabilities=experimental_capabilities,
34
+ **kwargs,
35
+ )
src/fastmcp/server/server.py CHANGED
@@ -23,7 +23,6 @@ import mcp.types
23
  import uvicorn
24
  from mcp.server.lowlevel.helper_types import ReadResourceContents
25
  from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
26
- from mcp.server.lowlevel.server import Server as MCPServer
27
  from mcp.server.stdio import stdio_server
28
  from mcp.types import (
29
  AnyFunction,
@@ -54,6 +53,7 @@ from fastmcp.server.http import (
54
  create_sse_app,
55
  create_streamable_http_app,
56
  )
 
57
  from fastmcp.server.middleware import Middleware, MiddlewareContext
58
  from fastmcp.settings import Settings
59
  from fastmcp.tools import ToolManager
@@ -99,10 +99,12 @@ def _lifespan_wrapper(
99
  [FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
100
  ],
101
  ) -> Callable[
102
- [MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
103
  ]:
104
  @asynccontextmanager
105
- async def wrap(s: MCPServer[LifespanResultT]) -> AsyncIterator[LifespanResultT]:
 
 
106
  async with AsyncExitStack() as stack:
107
  context = await stack.enter_async_context(lifespan(app))
108
  yield context
@@ -179,7 +181,7 @@ class FastMCP(Generic[LifespanResultT]):
179
  lifespan = default_lifespan
180
  else:
181
  self._has_lifespan = True
182
- self._mcp_server = MCPServer[LifespanResultT](
183
  name=name or "FastMCP",
184
  version=version,
185
  instructions=instructions,
@@ -431,7 +433,7 @@ class FastMCP(Generic[LifespanResultT]):
431
  async def _mcp_list_tools(self) -> list[MCPTool]:
432
  logger.debug("Handler called: list_tools")
433
 
434
- with fastmcp.server.context.Context(fastmcp=self):
435
  tools = await self._list_tools()
436
  return [tool.to_mcp_tool(name=tool.key) for tool in tools]
437
 
@@ -454,7 +456,7 @@ class FastMCP(Generic[LifespanResultT]):
454
 
455
  return mcp_tools
456
 
457
- with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
458
  # Create the middleware context.
459
  mw_context = MiddlewareContext(
460
  message=mcp.types.ListToolsRequest(method="tools/list"),
@@ -470,7 +472,7 @@ class FastMCP(Generic[LifespanResultT]):
470
  async def _mcp_list_resources(self) -> list[MCPResource]:
471
  logger.debug("Handler called: list_resources")
472
 
473
- with fastmcp.server.context.Context(fastmcp=self):
474
  resources = await self._list_resources()
475
  return [
476
  resource.to_mcp_resource(uri=resource.key) for resource in resources
@@ -495,7 +497,7 @@ class FastMCP(Generic[LifespanResultT]):
495
 
496
  return mcp_resources
497
 
498
- with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
499
  # Create the middleware context.
500
  mw_context = MiddlewareContext(
501
  message={}, # List resources doesn't have parameters
@@ -511,7 +513,7 @@ class FastMCP(Generic[LifespanResultT]):
511
  async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
512
  logger.debug("Handler called: list_resource_templates")
513
 
514
- with fastmcp.server.context.Context(fastmcp=self):
515
  templates = await self._list_resource_templates()
516
  return [
517
  template.to_mcp_template(uriTemplate=template.key)
@@ -537,7 +539,7 @@ class FastMCP(Generic[LifespanResultT]):
537
 
538
  return mcp_templates
539
 
540
- with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
541
  # Create the middleware context.
542
  mw_context = MiddlewareContext(
543
  message={}, # List resource templates doesn't have parameters
@@ -553,7 +555,7 @@ class FastMCP(Generic[LifespanResultT]):
553
  async def _mcp_list_prompts(self) -> list[MCPPrompt]:
554
  logger.debug("Handler called: list_prompts")
555
 
556
- with fastmcp.server.context.Context(fastmcp=self):
557
  prompts = await self._list_prompts()
558
  return [prompt.to_mcp_prompt(name=prompt.key) for prompt in prompts]
559
 
@@ -576,7 +578,7 @@ class FastMCP(Generic[LifespanResultT]):
576
 
577
  return mcp_prompts
578
 
579
- with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
580
  # Create the middleware context.
581
  mw_context = MiddlewareContext(
582
  message=mcp.types.ListPromptsRequest(method="prompts/list"),
@@ -606,7 +608,7 @@ class FastMCP(Generic[LifespanResultT]):
606
  """
607
  logger.debug("Handler called: call_tool %s with %s", key, arguments)
608
 
609
- with fastmcp.server.context.Context(fastmcp=self):
610
  try:
611
  return await self._call_tool(key, arguments)
612
  except DisabledError:
@@ -647,7 +649,7 @@ class FastMCP(Generic[LifespanResultT]):
647
  """
648
  logger.debug("Handler called: read_resource %s", uri)
649
 
650
- with fastmcp.server.context.Context(fastmcp=self):
651
  try:
652
  return await self._read_resource(uri)
653
  except DisabledError:
@@ -702,7 +704,7 @@ class FastMCP(Generic[LifespanResultT]):
702
  """
703
  logger.debug("Handler called: get_prompt %s with %s", name, arguments)
704
 
705
- with fastmcp.server.context.Context(fastmcp=self):
706
  try:
707
  return await self._get_prompt(name, arguments)
708
  except DisabledError:
@@ -751,6 +753,15 @@ class FastMCP(Generic[LifespanResultT]):
751
  self._tool_manager.add_tool(tool)
752
  self._cache.clear()
753
 
 
 
 
 
 
 
 
 
 
754
  def remove_tool(self, name: str) -> None:
755
  """Remove a tool from the server.
756
 
@@ -763,6 +774,15 @@ class FastMCP(Generic[LifespanResultT]):
763
  self._tool_manager.remove_tool(name)
764
  self._cache.clear()
765
 
 
 
 
 
 
 
 
 
 
766
  @overload
767
  def tool(
768
  self,
@@ -919,6 +939,15 @@ class FastMCP(Generic[LifespanResultT]):
919
  self._resource_manager.add_resource(resource)
920
  self._cache.clear()
921
 
 
 
 
 
 
 
 
 
 
922
  def add_template(self, template: ResourceTemplate) -> None:
923
  """Add a resource template to the server.
924
 
@@ -927,6 +956,15 @@ class FastMCP(Generic[LifespanResultT]):
927
  """
928
  self._resource_manager.add_template(template)
929
 
 
 
 
 
 
 
 
 
 
930
  def add_resource_fn(
931
  self,
932
  fn: AnyFunction,
@@ -1098,6 +1136,15 @@ class FastMCP(Generic[LifespanResultT]):
1098
  self._prompt_manager.add_prompt(prompt)
1099
  self._cache.clear()
1100
 
 
 
 
 
 
 
 
 
 
1101
  @overload
1102
  def prompt(
1103
  self,
 
23
  import uvicorn
24
  from mcp.server.lowlevel.helper_types import ReadResourceContents
25
  from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
 
26
  from mcp.server.stdio import stdio_server
27
  from mcp.types import (
28
  AnyFunction,
 
53
  create_sse_app,
54
  create_streamable_http_app,
55
  )
56
+ from fastmcp.server.low_level import LowLevelServer
57
  from fastmcp.server.middleware import Middleware, MiddlewareContext
58
  from fastmcp.settings import Settings
59
  from fastmcp.tools import ToolManager
 
99
  [FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
100
  ],
101
  ) -> Callable[
102
+ [LowLevelServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
103
  ]:
104
  @asynccontextmanager
105
+ async def wrap(
106
+ s: LowLevelServer[LifespanResultT],
107
+ ) -> AsyncIterator[LifespanResultT]:
108
  async with AsyncExitStack() as stack:
109
  context = await stack.enter_async_context(lifespan(app))
110
  yield context
 
181
  lifespan = default_lifespan
182
  else:
183
  self._has_lifespan = True
184
+ self._mcp_server = LowLevelServer[LifespanResultT](
185
  name=name or "FastMCP",
186
  version=version,
187
  instructions=instructions,
 
433
  async def _mcp_list_tools(self) -> list[MCPTool]:
434
  logger.debug("Handler called: list_tools")
435
 
436
+ async with fastmcp.server.context.Context(fastmcp=self):
437
  tools = await self._list_tools()
438
  return [tool.to_mcp_tool(name=tool.key) for tool in tools]
439
 
 
456
 
457
  return mcp_tools
458
 
459
+ async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
460
  # Create the middleware context.
461
  mw_context = MiddlewareContext(
462
  message=mcp.types.ListToolsRequest(method="tools/list"),
 
472
  async def _mcp_list_resources(self) -> list[MCPResource]:
473
  logger.debug("Handler called: list_resources")
474
 
475
+ async with fastmcp.server.context.Context(fastmcp=self):
476
  resources = await self._list_resources()
477
  return [
478
  resource.to_mcp_resource(uri=resource.key) for resource in resources
 
497
 
498
  return mcp_resources
499
 
500
+ async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
501
  # Create the middleware context.
502
  mw_context = MiddlewareContext(
503
  message={}, # List resources doesn't have parameters
 
513
  async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
514
  logger.debug("Handler called: list_resource_templates")
515
 
516
+ async with fastmcp.server.context.Context(fastmcp=self):
517
  templates = await self._list_resource_templates()
518
  return [
519
  template.to_mcp_template(uriTemplate=template.key)
 
539
 
540
  return mcp_templates
541
 
542
+ async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
543
  # Create the middleware context.
544
  mw_context = MiddlewareContext(
545
  message={}, # List resource templates doesn't have parameters
 
555
  async def _mcp_list_prompts(self) -> list[MCPPrompt]:
556
  logger.debug("Handler called: list_prompts")
557
 
558
+ async with fastmcp.server.context.Context(fastmcp=self):
559
  prompts = await self._list_prompts()
560
  return [prompt.to_mcp_prompt(name=prompt.key) for prompt in prompts]
561
 
 
578
 
579
  return mcp_prompts
580
 
581
+ async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
582
  # Create the middleware context.
583
  mw_context = MiddlewareContext(
584
  message=mcp.types.ListPromptsRequest(method="prompts/list"),
 
608
  """
609
  logger.debug("Handler called: call_tool %s with %s", key, arguments)
610
 
611
+ async with fastmcp.server.context.Context(fastmcp=self):
612
  try:
613
  return await self._call_tool(key, arguments)
614
  except DisabledError:
 
649
  """
650
  logger.debug("Handler called: read_resource %s", uri)
651
 
652
+ async with fastmcp.server.context.Context(fastmcp=self):
653
  try:
654
  return await self._read_resource(uri)
655
  except DisabledError:
 
704
  """
705
  logger.debug("Handler called: get_prompt %s with %s", name, arguments)
706
 
707
+ async with fastmcp.server.context.Context(fastmcp=self):
708
  try:
709
  return await self._get_prompt(name, arguments)
710
  except DisabledError:
 
753
  self._tool_manager.add_tool(tool)
754
  self._cache.clear()
755
 
756
+ # Send notification if we're in a request context
757
+ try:
758
+ from fastmcp.server.dependencies import get_context
759
+
760
+ context = get_context()
761
+ context._queue_tool_list_changed() # type: ignore[private-use]
762
+ except RuntimeError:
763
+ pass # No context available
764
+
765
  def remove_tool(self, name: str) -> None:
766
  """Remove a tool from the server.
767
 
 
774
  self._tool_manager.remove_tool(name)
775
  self._cache.clear()
776
 
777
+ # Send notification if we're in a request context
778
+ try:
779
+ from fastmcp.server.dependencies import get_context
780
+
781
+ context = get_context()
782
+ context._queue_tool_list_changed() # type: ignore[private-use]
783
+ except RuntimeError:
784
+ pass # No context available
785
+
786
  @overload
787
  def tool(
788
  self,
 
939
  self._resource_manager.add_resource(resource)
940
  self._cache.clear()
941
 
942
+ # Send notification if we're in a request context
943
+ try:
944
+ from fastmcp.server.dependencies import get_context
945
+
946
+ context = get_context()
947
+ context._queue_resource_list_changed() # type: ignore[private-use]
948
+ except RuntimeError:
949
+ pass # No context available
950
+
951
  def add_template(self, template: ResourceTemplate) -> None:
952
  """Add a resource template to the server.
953
 
 
956
  """
957
  self._resource_manager.add_template(template)
958
 
959
+ # Send notification if we're in a request context
960
+ try:
961
+ from fastmcp.server.dependencies import get_context
962
+
963
+ context = get_context()
964
+ context._queue_resource_list_changed() # type: ignore[private-use]
965
+ except RuntimeError:
966
+ pass # No context available
967
+
968
  def add_resource_fn(
969
  self,
970
  fn: AnyFunction,
 
1136
  self._prompt_manager.add_prompt(prompt)
1137
  self._cache.clear()
1138
 
1139
+ # Send notification if we're in a request context
1140
+ try:
1141
+ from fastmcp.server.dependencies import get_context
1142
+
1143
+ context = get_context()
1144
+ context._queue_prompt_list_changed() # type: ignore[private-use]
1145
+ except RuntimeError:
1146
+ pass # No context available
1147
+
1148
  @overload
1149
  def prompt(
1150
  self,
src/fastmcp/tools/tool.py CHANGED
@@ -46,6 +46,22 @@ class Tool(FastMCPComponent):
46
  default=None, description="Optional custom serializer for tool results"
47
  )
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  def to_mcp_tool(self, **overrides: Any) -> MCPTool:
50
  kwargs = {
51
  "name": self.name,
 
46
  default=None, description="Optional custom serializer for tool results"
47
  )
48
 
49
+ def enable(self) -> None:
50
+ super().enable()
51
+ try:
52
+ context = get_context()
53
+ context._queue_tool_list_changed() # type: ignore[private-use]
54
+ except RuntimeError:
55
+ pass # No context available
56
+
57
+ def disable(self) -> None:
58
+ super().disable()
59
+ try:
60
+ context = get_context()
61
+ context._queue_tool_list_changed() # type: ignore[private-use]
62
+ except RuntimeError:
63
+ pass # No context available
64
+
65
  def to_mcp_tool(self, **overrides: Any) -> MCPTool:
66
  kwargs = {
67
  "name": self.name,
tests/client/test_notifications.py ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+
3
+ import mcp.types
4
+ import pytest
5
+
6
+ from fastmcp import Client, FastMCP
7
+ from fastmcp.client.messages import MessageHandler
8
+ from fastmcp.server.context import Context
9
+ from fastmcp.tools.tool import Tool
10
+
11
+
12
+ @dataclass
13
+ class NotificationRecording:
14
+ """Record of a notification that was received."""
15
+
16
+ method: str
17
+ notification: mcp.types.ServerNotification
18
+
19
+
20
+ class RecordingMessageHandler(MessageHandler):
21
+ """A message handler that records all notifications."""
22
+
23
+ def __init__(self, name: str | None = None):
24
+ super().__init__()
25
+ self.notifications: list[NotificationRecording] = []
26
+ self.name = name
27
+
28
+ async def on_notification(self, message: mcp.types.ServerNotification) -> None:
29
+ """Record all notifications."""
30
+ self.notifications.append(
31
+ NotificationRecording(method=message.root.method, notification=message)
32
+ )
33
+
34
+ def get_notifications(
35
+ self, method: str | None = None
36
+ ) -> list[NotificationRecording]:
37
+ """Get all recorded notifications, optionally filtered by method."""
38
+ if method is None:
39
+ return self.notifications
40
+ return [n for n in self.notifications if n.method == method]
41
+
42
+ def assert_notification_sent(self, method: str, times: int = 1) -> bool:
43
+ """Assert that a notification was sent a specific number of times."""
44
+ notifications = self.get_notifications(method)
45
+ actual_times = len(notifications)
46
+ assert actual_times == times, (
47
+ f"Expected {times} notifications for {method}, "
48
+ f"but received {actual_times} notifications"
49
+ )
50
+ return True
51
+
52
+ def assert_notification_not_sent(self, method: str) -> bool:
53
+ """Assert that a notification was not sent."""
54
+ notifications = self.get_notifications(method)
55
+ assert len(notifications) == 0, (
56
+ f"Expected no notifications for {method}, but received {len(notifications)}"
57
+ )
58
+ return True
59
+
60
+ def reset(self):
61
+ """Clear all recorded notifications."""
62
+ self.notifications.clear()
63
+
64
+
65
+ @pytest.fixture
66
+ def recording_message_handler():
67
+ """Fixture that provides a recording message handler instance."""
68
+ handler = RecordingMessageHandler(name="recording_message_handler")
69
+ yield handler
70
+
71
+
72
+ @pytest.fixture
73
+ def notification_test_server(recording_message_handler):
74
+ """Create a server for testing notifications."""
75
+ mcp = FastMCP(name="NotificationTestServer")
76
+
77
+ # Create a target tool that can be enabled/disabled
78
+ def target_tool() -> str:
79
+ """A tool that can be enabled/disabled."""
80
+ return "Target tool executed"
81
+
82
+ target_tool_obj = Tool.from_function(target_tool)
83
+ mcp.add_tool(target_tool_obj)
84
+
85
+ # Tool to enable the target tool
86
+ @mcp.tool
87
+ async def enable_target_tool(ctx: Context) -> str:
88
+ """Enable the target tool."""
89
+ # Find and enable the target tool
90
+ try:
91
+ tool = await ctx.fastmcp.get_tool("target_tool")
92
+ tool.enable()
93
+ return "Target tool enabled"
94
+ except Exception:
95
+ return "Target tool not found"
96
+
97
+ # Tool to disable the target tool
98
+ @mcp.tool
99
+ async def disable_target_tool(ctx: Context) -> str:
100
+ """Disable the target tool."""
101
+ # Find and disable the target tool
102
+ try:
103
+ tool = await ctx.fastmcp.get_tool("target_tool")
104
+ tool.disable()
105
+ return "Target tool disabled"
106
+ except Exception:
107
+ return "Target tool not found"
108
+
109
+ return mcp
110
+
111
+
112
+ class TestToolNotifications:
113
+ """Test tool list changed notifications."""
114
+
115
+ async def test_tool_enable_sends_notification(
116
+ self,
117
+ notification_test_server: FastMCP,
118
+ recording_message_handler: RecordingMessageHandler,
119
+ ):
120
+ """Test that enabling a tool sends a tool list changed notification."""
121
+ async with Client(
122
+ notification_test_server, message_handler=recording_message_handler
123
+ ) as client:
124
+ # Reset any initialization notifications
125
+ recording_message_handler.reset()
126
+
127
+ # Enable the target tool
128
+ result = await client.call_tool("enable_target_tool", {})
129
+ assert result[0].text == "Target tool enabled" # type: ignore[attr-defined]
130
+
131
+ # Check that notification was sent
132
+ recording_message_handler.assert_notification_sent(
133
+ "notifications/tools/list_changed", times=1
134
+ )
135
+
136
+ async def test_tool_disable_sends_notification(
137
+ self,
138
+ notification_test_server: FastMCP,
139
+ recording_message_handler: RecordingMessageHandler,
140
+ ):
141
+ """Test that disabling a tool sends a tool list changed notification."""
142
+ async with Client(
143
+ notification_test_server, message_handler=recording_message_handler
144
+ ) as client:
145
+ # Reset any initialization notifications
146
+ recording_message_handler.reset()
147
+
148
+ # Disable the target tool
149
+ result = await client.call_tool("disable_target_tool", {})
150
+ assert result[0].text == "Target tool disabled" # type: ignore[attr-defined]
151
+
152
+ # Check that notification was sent
153
+ recording_message_handler.assert_notification_sent(
154
+ "notifications/tools/list_changed", times=1
155
+ )
156
+
157
+ async def test_multiple_tool_changes_deduplicates_notifications(
158
+ self,
159
+ notification_test_server: FastMCP,
160
+ recording_message_handler: RecordingMessageHandler,
161
+ ):
162
+ """Test that multiple rapid tool changes result in a single notification."""
163
+ async with Client(
164
+ notification_test_server, message_handler=recording_message_handler
165
+ ) as client:
166
+ # Reset any initialization notifications
167
+ recording_message_handler.reset()
168
+
169
+ # Enable and disable multiple times in the same context
170
+ # This should result in deduplication
171
+ await client.call_tool("enable_target_tool", {})
172
+ await client.call_tool("disable_target_tool", {})
173
+ await client.call_tool("enable_target_tool", {})
174
+
175
+ # Should have 3 notifications (one per tool call context)
176
+ recording_message_handler.assert_notification_sent(
177
+ "notifications/tools/list_changed", times=3
178
+ )
179
+
180
+
181
+ @pytest.fixture
182
+ def resource_notification_test_server(recording_message_handler):
183
+ """Create a server for testing resource notifications."""
184
+ mcp = FastMCP(name="ResourceNotificationTestServer")
185
+
186
+ # Create a target resource that can be enabled/disabled
187
+ @mcp.resource("resource://target")
188
+ def target_resource() -> str:
189
+ """A resource that can be enabled/disabled."""
190
+ return "Target resource content"
191
+
192
+ # Tool to enable the target resource
193
+ @mcp.tool
194
+ async def enable_target_resource(ctx: Context) -> str:
195
+ """Enable the target resource."""
196
+ try:
197
+ resource = await ctx.fastmcp.get_resource("resource://target")
198
+ resource.enable()
199
+ return "Target resource enabled"
200
+ except Exception:
201
+ return "Target resource not found"
202
+
203
+ # Tool to disable the target resource
204
+ @mcp.tool
205
+ async def disable_target_resource(ctx: Context) -> str:
206
+ """Disable the target resource."""
207
+ try:
208
+ resource = await ctx.fastmcp.get_resource("resource://target")
209
+ resource.disable()
210
+ return "Target resource disabled"
211
+ except Exception:
212
+ return "Target resource not found"
213
+
214
+ return mcp
215
+
216
+
217
+ class TestResourceNotifications:
218
+ """Test resource list changed notifications."""
219
+
220
+ async def test_resource_enable_sends_notification(
221
+ self,
222
+ resource_notification_test_server: FastMCP,
223
+ recording_message_handler: RecordingMessageHandler,
224
+ ):
225
+ """Test that enabling a resource sends a resource list changed notification."""
226
+ async with Client(
227
+ resource_notification_test_server, message_handler=recording_message_handler
228
+ ) as client:
229
+ # Reset any initialization notifications
230
+ recording_message_handler.reset()
231
+
232
+ # Enable the target resource
233
+ result = await client.call_tool("enable_target_resource", {})
234
+ assert result[0].text == "Target resource enabled" # type: ignore[attr-defined]
235
+
236
+ # Check that notification was sent
237
+ recording_message_handler.assert_notification_sent(
238
+ "notifications/resources/list_changed", times=1
239
+ )
240
+
241
+ async def test_resource_disable_sends_notification(
242
+ self,
243
+ resource_notification_test_server: FastMCP,
244
+ recording_message_handler: RecordingMessageHandler,
245
+ ):
246
+ """Test that disabling a resource sends a resource list changed notification."""
247
+ async with Client(
248
+ resource_notification_test_server, message_handler=recording_message_handler
249
+ ) as client:
250
+ # Reset any initialization notifications
251
+ recording_message_handler.reset()
252
+
253
+ # Disable the target resource
254
+ result = await client.call_tool("disable_target_resource", {})
255
+ assert result[0].text == "Target resource disabled" # type: ignore[attr-defined]
256
+
257
+ # Check that notification was sent
258
+ recording_message_handler.assert_notification_sent(
259
+ "notifications/resources/list_changed", times=1
260
+ )
261
+
262
+
263
+ @pytest.fixture
264
+ def prompt_notification_test_server(recording_message_handler):
265
+ """Create a server for testing prompt notifications."""
266
+ mcp = FastMCP(name="PromptNotificationTestServer")
267
+
268
+ # Create a target prompt that can be enabled/disabled
269
+ @mcp.prompt
270
+ def target_prompt() -> str:
271
+ """A prompt that can be enabled/disabled."""
272
+ return "Target prompt content"
273
+
274
+ # Tool to enable the target prompt
275
+ @mcp.tool
276
+ async def enable_target_prompt(ctx: Context) -> str:
277
+ """Enable the target prompt."""
278
+ try:
279
+ prompt = await ctx.fastmcp.get_prompt("target_prompt")
280
+ prompt.enable()
281
+ return "Target prompt enabled"
282
+ except Exception:
283
+ return "Target prompt not found"
284
+
285
+ # Tool to disable the target prompt
286
+ @mcp.tool
287
+ async def disable_target_prompt(ctx: Context) -> str:
288
+ """Disable the target prompt."""
289
+ try:
290
+ prompt = await ctx.fastmcp.get_prompt("target_prompt")
291
+ prompt.disable()
292
+ return "Target prompt disabled"
293
+ except Exception:
294
+ return "Target prompt not found"
295
+
296
+ return mcp
297
+
298
+
299
+ class TestPromptNotifications:
300
+ """Test prompt list changed notifications."""
301
+
302
+ async def test_prompt_enable_sends_notification(
303
+ self,
304
+ prompt_notification_test_server: FastMCP,
305
+ recording_message_handler: RecordingMessageHandler,
306
+ ):
307
+ """Test that enabling a prompt sends a prompt list changed notification."""
308
+ async with Client(
309
+ prompt_notification_test_server, message_handler=recording_message_handler
310
+ ) as client:
311
+ # Reset any initialization notifications
312
+ recording_message_handler.reset()
313
+
314
+ # Enable the target prompt
315
+ result = await client.call_tool("enable_target_prompt", {})
316
+ assert result[0].text == "Target prompt enabled" # type: ignore[attr-defined]
317
+
318
+ # Check that notification was sent
319
+ recording_message_handler.assert_notification_sent(
320
+ "notifications/prompts/list_changed", times=1
321
+ )
322
+
323
+ async def test_prompt_disable_sends_notification(
324
+ self,
325
+ prompt_notification_test_server: FastMCP,
326
+ recording_message_handler: RecordingMessageHandler,
327
+ ):
328
+ """Test that disabling a prompt sends a prompt list changed notification."""
329
+ async with Client(
330
+ prompt_notification_test_server, message_handler=recording_message_handler
331
+ ) as client:
332
+ # Reset any initialization notifications
333
+ recording_message_handler.reset()
334
+
335
+ # Disable the target prompt
336
+ result = await client.call_tool("disable_target_prompt", {})
337
+ assert result[0].text == "Target prompt disabled" # type: ignore[attr-defined]
338
+
339
+ # Check that notification was sent
340
+ recording_message_handler.assert_notification_sent(
341
+ "notifications/prompts/list_changed", times=1
342
+ )
343
+
344
+
345
+ class TestMessageHandlerGeneral:
346
+ """Test the message handler functionality in general."""
347
+
348
+ async def test_message_handler_receives_all_notifications(
349
+ self,
350
+ notification_test_server: FastMCP,
351
+ recording_message_handler: RecordingMessageHandler,
352
+ ):
353
+ """Test that the message handler receives all types of notifications."""
354
+ async with Client(
355
+ notification_test_server, message_handler=recording_message_handler
356
+ ) as client:
357
+ recording_message_handler.reset()
358
+
359
+ # Trigger a tool notification
360
+ await client.call_tool("enable_target_tool", {})
361
+
362
+ # Verify the handler received the notification
363
+ all_notifications = recording_message_handler.get_notifications()
364
+ assert len(all_notifications) == 1
365
+ assert all_notifications[0].method == "notifications/tools/list_changed"
366
+
367
+ async def test_message_handler_notification_filtering(
368
+ self,
369
+ notification_test_server: FastMCP,
370
+ recording_message_handler: RecordingMessageHandler,
371
+ ):
372
+ """Test that notification filtering works correctly."""
373
+ async with Client(
374
+ notification_test_server, message_handler=recording_message_handler
375
+ ) as client:
376
+ recording_message_handler.reset()
377
+
378
+ # Trigger tool notifications
379
+ await client.call_tool("enable_target_tool", {})
380
+ await client.call_tool("disable_target_tool", {})
381
+
382
+ # Test filtering
383
+ tool_notifications = recording_message_handler.get_notifications(
384
+ "notifications/tools/list_changed"
385
+ )
386
+ assert len(tool_notifications) == 2
387
+
388
+ # Test non-existent filter
389
+ resource_notifications = recording_message_handler.get_notifications(
390
+ "notifications/resources/list_changed"
391
+ )
392
+ assert len(resource_notifications) == 0
393
+
394
+ async def test_notification_structure(
395
+ self,
396
+ notification_test_server: FastMCP,
397
+ recording_message_handler: RecordingMessageHandler,
398
+ ):
399
+ """Test that notifications have the correct structure."""
400
+ async with Client(
401
+ notification_test_server, message_handler=recording_message_handler
402
+ ) as client:
403
+ recording_message_handler.reset()
404
+
405
+ # Trigger a notification
406
+ await client.call_tool("enable_target_tool", {})
407
+
408
+ # Check notification structure
409
+ notifications = recording_message_handler.get_notifications(
410
+ "notifications/tools/list_changed"
411
+ )
412
+ assert len(notifications) == 1
413
+
414
+ notification = notifications[0]
415
+ assert isinstance(notification.notification, mcp.types.ServerNotification)
416
+ assert isinstance(
417
+ notification.notification.root, mcp.types.ToolListChangedNotification
418
+ )
419
+ assert (
420
+ notification.notification.root.method
421
+ == "notifications/tools/list_changed"
422
+ )
tests/prompts/test_prompt_manager.py CHANGED
@@ -391,7 +391,7 @@ class TestContextHandling:
391
  mcp = FastMCP()
392
  context = Context(fastmcp=mcp)
393
 
394
- with context:
395
  messages = await prompt.render(arguments={"x": 42})
396
 
397
  assert len(messages) == 1
@@ -411,7 +411,7 @@ class TestContextHandling:
411
  mcp = FastMCP()
412
  context = Context(fastmcp=mcp)
413
 
414
- with context:
415
  messages = await prompt.render(
416
  arguments={"x": 42},
417
  )
 
391
  mcp = FastMCP()
392
  context = Context(fastmcp=mcp)
393
 
394
+ async with context:
395
  messages = await prompt.render(arguments={"x": 42})
396
 
397
  assert len(messages) == 1
 
411
  mcp = FastMCP()
412
  context = Context(fastmcp=mcp)
413
 
414
+ async with context:
415
  messages = await prompt.render(
416
  arguments={"x": 42},
417
  )
tests/resources/test_resource_template.py CHANGED
@@ -670,7 +670,7 @@ class TestContextHandling:
670
  mcp = FastMCP()
671
  context = Context(fastmcp=mcp)
672
 
673
- with context:
674
  resource = await template.create_resource(
675
  "test://42",
676
  {"x": 42},
@@ -698,7 +698,7 @@ class TestContextHandling:
698
  mcp = FastMCP()
699
  context = Context(fastmcp=mcp)
700
 
701
- with context:
702
  resource = await template.create_resource(
703
  "test://42",
704
  {"x": 42},
 
670
  mcp = FastMCP()
671
  context = Context(fastmcp=mcp)
672
 
673
+ async with context:
674
  resource = await template.create_resource(
675
  "test://42",
676
  {"x": 42},
 
698
  mcp = FastMCP()
699
  context = Context(fastmcp=mcp)
700
 
701
+ async with context:
702
  resource = await template.create_resource(
703
  "test://42",
704
  {"x": 42},
tests/tools/test_tool_manager.py CHANGED
@@ -499,7 +499,7 @@ class TestCallTools:
499
  mcp = FastMCP()
500
  context = Context(fastmcp=mcp)
501
 
502
- with context:
503
  result = await manager.call_tool(
504
  "name_shrimp",
505
  {
@@ -639,7 +639,7 @@ class TestContextHandling:
639
  mcp = FastMCP()
640
  context = Context(fastmcp=mcp)
641
 
642
- with context:
643
  result = await manager.call_tool("tool_with_context", {"x": 42})
644
  assert result[0].text == "42" # type: ignore[attr-defined]
645
 
@@ -657,7 +657,7 @@ class TestContextHandling:
657
  mcp = FastMCP()
658
  context = Context(fastmcp=mcp)
659
 
660
- with context:
661
  result = await manager.call_tool("async_tool", {"x": 42})
662
  assert result[0].text == "42" # type: ignore[attr-defined]
663
 
@@ -675,7 +675,7 @@ class TestContextHandling:
675
  mcp = FastMCP()
676
  context = Context(fastmcp=mcp)
677
 
678
- with context:
679
  result = await manager.call_tool("tool_with_context", {"x": 42})
680
  assert result[0].text == "42" # type: ignore[attr-defined]
681
 
@@ -722,7 +722,7 @@ class TestContextHandling:
722
  mcp = FastMCP()
723
  context = Context(fastmcp=mcp)
724
 
725
- with context:
726
  with pytest.raises(
727
  ToolError, match="Error calling tool 'tool_with_context'"
728
  ):
 
499
  mcp = FastMCP()
500
  context = Context(fastmcp=mcp)
501
 
502
+ async with context:
503
  result = await manager.call_tool(
504
  "name_shrimp",
505
  {
 
639
  mcp = FastMCP()
640
  context = Context(fastmcp=mcp)
641
 
642
+ async with context:
643
  result = await manager.call_tool("tool_with_context", {"x": 42})
644
  assert result[0].text == "42" # type: ignore[attr-defined]
645
 
 
657
  mcp = FastMCP()
658
  context = Context(fastmcp=mcp)
659
 
660
+ async with context:
661
  result = await manager.call_tool("async_tool", {"x": 42})
662
  assert result[0].text == "42" # type: ignore[attr-defined]
663
 
 
675
  mcp = FastMCP()
676
  context = Context(fastmcp=mcp)
677
 
678
+ async with context:
679
  result = await manager.call_tool("tool_with_context", {"x": 42})
680
  assert result[0].text == "42" # type: ignore[attr-defined]
681
 
 
722
  mcp = FastMCP()
723
  context = Context(fastmcp=mcp)
724
 
725
+ async with context:
726
  with pytest.raises(
727
  ToolError, match="Error calling tool 'tool_with_context'"
728
  ):