@@ -232,6 +232,12 @@ class ServerCapabilities:
232232 tools : bool = False
233233 """Whether the server offers any tools to call."""
234234
235+ tools_list_changed : bool = False
236+ """Whether the server will emit notifications when the list of tools changes."""
237+
238+ resources_list_changed : bool = False
239+ """Whether the server will emit notifications when the list of resources changes."""
240+
235241 completions : bool = False
236242 """Whether the server offers autocompletion suggestions for prompts and resources."""
237243
@@ -244,12 +250,16 @@ def from_mcp_sdk(cls, mcp_capabilities: mcp_types.ServerCapabilities) -> ServerC
244250 Args:
245251 mcp_capabilities: The MCP SDK ServerCapabilities object.
246252 """
253+ tools_cap = mcp_capabilities .tools
254+ resources_cap = mcp_capabilities .resources
247255 return cls (
248256 experimental = list (mcp_capabilities .experimental .keys ()) if mcp_capabilities .experimental else None ,
249257 logging = mcp_capabilities .logging is not None ,
250258 prompts = mcp_capabilities .prompts is not None ,
251- resources = mcp_capabilities .resources is not None ,
252- tools = mcp_capabilities .tools is not None ,
259+ resources = resources_cap is not None ,
260+ tools = tools_cap is not None ,
261+ tools_list_changed = bool (tools_cap .listChanged ) if tools_cap else False ,
262+ resources_list_changed = bool (resources_cap .listChanged ) if resources_cap else False ,
253263 completions = mcp_capabilities .completions is not None ,
254264 )
255265
@@ -332,6 +342,11 @@ class MCPServer(AbstractToolset[Any], ABC):
332342 _server_capabilities : ServerCapabilities
333343 _instructions : str | None
334344
345+ _cached_tools : list [mcp_types .Tool ] | None
346+ _tools_cache_valid : bool
347+ _cached_resources : list [Resource ] | None
348+ _resources_cache_valid : bool
349+
335350 def __init__ (
336351 self ,
337352 tool_prefix : str | None = None ,
@@ -366,6 +381,10 @@ def __post_init__(self):
366381 self ._enter_lock = Lock ()
367382 self ._running_count = 0
368383 self ._exit_stack = None
384+ self ._cached_tools = None
385+ self ._tools_cache_valid = False
386+ self ._cached_resources = None
387+ self ._resources_cache_valid = False
369388
370389 @abstractmethod
371390 @asynccontextmanager
@@ -430,13 +449,23 @@ def instructions(self) -> str | None:
430449 async def list_tools (self ) -> list [mcp_types .Tool ]:
431450 """Retrieve tools that are currently active on the server.
432451
433- Note:
434- - We don't cache tools as they might change.
435- - We also don't subscribe to the server to avoid complexity.
452+ Tools are cached when the server advertises `tools.listChanged` capability,
453+ with cache invalidation on tool change notifications and reconnection.
436454 """
437455 async with self : # Ensure server is running
438- result = await self ._client .list_tools ()
439- return result .tools
456+ # Only cache if server supports listChanged notifications
457+ if self ._server_capabilities .tools_list_changed :
458+ if self ._cached_tools is not None and self ._tools_cache_valid :
459+ return self ._cached_tools
460+
461+ result = await self ._client .list_tools ()
462+ self ._cached_tools = result .tools
463+ self ._tools_cache_valid = True
464+ return result .tools
465+ else :
466+ # Server doesn't support notifications, always fetch fresh
467+ result = await self ._client .list_tools ()
468+ return result .tools
440469
441470 async def direct_call_tool (
442471 self ,
@@ -542,9 +571,8 @@ def tool_for_tool_def(self, tool_def: ToolDefinition) -> ToolsetTool[Any]:
542571 async def list_resources (self ) -> list [Resource ]:
543572 """Retrieve resources that are currently present on the server.
544573
545- Note:
546- - We don't cache resources as they might change.
547- - We also don't subscribe to resource changes to avoid complexity.
574+ Resources are cached when the server advertises `resources.listChanged` capability,
575+ with cache invalidation on resource change notifications and reconnection.
548576
549577 Raises:
550578 MCPError: If the server returns an error.
@@ -553,10 +581,21 @@ async def list_resources(self) -> list[Resource]:
553581 if not self .capabilities .resources :
554582 return []
555583 try :
556- result = await self ._client .list_resources ()
584+ # caching logic same as list_tools
585+ if self ._server_capabilities .resources_list_changed :
586+ if self ._cached_resources is not None and self ._resources_cache_valid :
587+ return self ._cached_resources
588+
589+ result = await self ._client .list_resources ()
590+ resources = [Resource .from_mcp_sdk (r ) for r in result .resources ]
591+ self ._cached_resources = resources
592+ self ._resources_cache_valid = True
593+ return resources
594+ else :
595+ result = await self ._client .list_resources ()
596+ return [Resource .from_mcp_sdk (r ) for r in result .resources ]
557597 except mcp_exceptions .McpError as e :
558598 raise MCPError .from_mcp_sdk (e ) from e
559- return [Resource .from_mcp_sdk (r ) for r in result .resources ]
560599
561600 async def list_resource_templates (self ) -> list [ResourceTemplate ]:
562601 """Retrieve resource templates that are currently present on the server.
@@ -619,6 +658,12 @@ async def __aenter__(self) -> Self:
619658 """
620659 async with self ._enter_lock :
621660 if self ._running_count == 0 :
661+ # Invalidate caches on fresh connection
662+ self ._cached_tools = None
663+ self ._tools_cache_valid = False
664+ self ._cached_resources = None
665+ self ._resources_cache_valid = False
666+
622667 async with AsyncExitStack () as exit_stack :
623668 self ._read_stream , self ._write_stream = await exit_stack .enter_async_context (self .client_streams ())
624669 client = ClientSession (
@@ -628,6 +673,7 @@ async def __aenter__(self) -> Self:
628673 elicitation_callback = self .elicitation_callback ,
629674 logging_callback = self .log_handler ,
630675 read_timeout_seconds = timedelta (seconds = self .read_timeout ),
676+ message_handler = self ._handle_notification ,
631677 )
632678 self ._client = await exit_stack .enter_async_context (client )
633679
@@ -680,6 +726,13 @@ async def _sampling_callback(
680726 model = self .sampling_model .model_name ,
681727 )
682728
729+ async def _handle_notification (self , message : Any ) -> None :
730+ """Handle notifications from the MCP server, invalidating caches as needed."""
731+ if isinstance (message , mcp_types .ToolListChangedNotification ):
732+ self ._tools_cache_valid = False
733+ elif isinstance (message , mcp_types .ResourceListChangedNotification ):
734+ self ._resources_cache_valid = False
735+
683736 async def _map_tool_result_part (
684737 self , part : mcp_types .ContentBlock
685738 ) -> str | messages .BinaryContent | dict [str , Any ] | list [Any ]:
0 commit comments