fastmcp 2.12.5__py3-none-any.whl → 2.13.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. fastmcp/__init__.py +2 -2
  2. fastmcp/cli/cli.py +11 -11
  3. fastmcp/cli/install/claude_code.py +6 -6
  4. fastmcp/cli/install/claude_desktop.py +3 -3
  5. fastmcp/cli/install/cursor.py +18 -12
  6. fastmcp/cli/install/gemini_cli.py +3 -3
  7. fastmcp/cli/install/mcp_json.py +3 -3
  8. fastmcp/cli/run.py +13 -8
  9. fastmcp/client/__init__.py +9 -9
  10. fastmcp/client/auth/oauth.py +115 -217
  11. fastmcp/client/client.py +105 -39
  12. fastmcp/client/logging.py +18 -14
  13. fastmcp/client/oauth_callback.py +85 -171
  14. fastmcp/client/sampling.py +1 -1
  15. fastmcp/client/transports.py +80 -25
  16. fastmcp/contrib/component_manager/__init__.py +1 -1
  17. fastmcp/contrib/component_manager/component_manager.py +2 -2
  18. fastmcp/contrib/component_manager/component_service.py +6 -6
  19. fastmcp/contrib/mcp_mixin/README.md +32 -1
  20. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  21. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
  22. fastmcp/experimental/sampling/handlers/openai.py +2 -2
  23. fastmcp/experimental/server/openapi/__init__.py +5 -8
  24. fastmcp/experimental/server/openapi/components.py +11 -7
  25. fastmcp/experimental/server/openapi/routing.py +2 -2
  26. fastmcp/experimental/utilities/openapi/__init__.py +10 -15
  27. fastmcp/experimental/utilities/openapi/director.py +14 -15
  28. fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
  29. fastmcp/experimental/utilities/openapi/models.py +3 -3
  30. fastmcp/experimental/utilities/openapi/parser.py +37 -16
  31. fastmcp/experimental/utilities/openapi/schemas.py +2 -2
  32. fastmcp/mcp_config.py +3 -4
  33. fastmcp/prompts/__init__.py +1 -1
  34. fastmcp/prompts/prompt.py +22 -19
  35. fastmcp/prompts/prompt_manager.py +16 -101
  36. fastmcp/resources/__init__.py +5 -5
  37. fastmcp/resources/resource.py +14 -9
  38. fastmcp/resources/resource_manager.py +9 -168
  39. fastmcp/resources/template.py +107 -17
  40. fastmcp/resources/types.py +30 -24
  41. fastmcp/server/__init__.py +1 -1
  42. fastmcp/server/auth/__init__.py +9 -5
  43. fastmcp/server/auth/auth.py +70 -43
  44. fastmcp/server/auth/handlers/authorize.py +326 -0
  45. fastmcp/server/auth/jwt_issuer.py +236 -0
  46. fastmcp/server/auth/middleware.py +96 -0
  47. fastmcp/server/auth/oauth_proxy.py +1510 -289
  48. fastmcp/server/auth/oidc_proxy.py +84 -20
  49. fastmcp/server/auth/providers/auth0.py +40 -21
  50. fastmcp/server/auth/providers/aws.py +29 -3
  51. fastmcp/server/auth/providers/azure.py +312 -131
  52. fastmcp/server/auth/providers/bearer.py +1 -1
  53. fastmcp/server/auth/providers/debug.py +114 -0
  54. fastmcp/server/auth/providers/descope.py +86 -29
  55. fastmcp/server/auth/providers/discord.py +308 -0
  56. fastmcp/server/auth/providers/github.py +29 -8
  57. fastmcp/server/auth/providers/google.py +48 -9
  58. fastmcp/server/auth/providers/in_memory.py +27 -3
  59. fastmcp/server/auth/providers/introspection.py +281 -0
  60. fastmcp/server/auth/providers/jwt.py +48 -31
  61. fastmcp/server/auth/providers/oci.py +233 -0
  62. fastmcp/server/auth/providers/scalekit.py +238 -0
  63. fastmcp/server/auth/providers/supabase.py +188 -0
  64. fastmcp/server/auth/providers/workos.py +35 -17
  65. fastmcp/server/context.py +177 -51
  66. fastmcp/server/dependencies.py +39 -12
  67. fastmcp/server/elicitation.py +1 -1
  68. fastmcp/server/http.py +56 -17
  69. fastmcp/server/low_level.py +121 -2
  70. fastmcp/server/middleware/__init__.py +1 -1
  71. fastmcp/server/middleware/caching.py +476 -0
  72. fastmcp/server/middleware/error_handling.py +14 -10
  73. fastmcp/server/middleware/logging.py +50 -39
  74. fastmcp/server/middleware/middleware.py +29 -16
  75. fastmcp/server/middleware/rate_limiting.py +3 -3
  76. fastmcp/server/middleware/tool_injection.py +116 -0
  77. fastmcp/server/openapi.py +10 -6
  78. fastmcp/server/proxy.py +22 -11
  79. fastmcp/server/server.py +725 -242
  80. fastmcp/settings.py +24 -10
  81. fastmcp/tools/__init__.py +1 -1
  82. fastmcp/tools/tool.py +70 -23
  83. fastmcp/tools/tool_manager.py +30 -112
  84. fastmcp/tools/tool_transform.py +12 -10
  85. fastmcp/utilities/cli.py +67 -28
  86. fastmcp/utilities/components.py +7 -2
  87. fastmcp/utilities/inspect.py +79 -23
  88. fastmcp/utilities/json_schema.py +4 -4
  89. fastmcp/utilities/json_schema_type.py +4 -4
  90. fastmcp/utilities/logging.py +118 -8
  91. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  92. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  93. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  94. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +4 -4
  95. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  96. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  97. fastmcp/utilities/openapi.py +11 -11
  98. fastmcp/utilities/tests.py +85 -4
  99. fastmcp/utilities/types.py +78 -16
  100. fastmcp/utilities/ui.py +626 -0
  101. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/METADATA +22 -14
  102. fastmcp-2.13.2.dist-info/RECORD +144 -0
  103. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
  104. fastmcp/cli/claude.py +0 -135
  105. fastmcp/utilities/storage.py +0 -204
  106. fastmcp-2.12.5.dist-info/RECORD +0 -134
  107. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
  108. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,12 @@
1
- from typing import Any
1
+ from __future__ import annotations
2
2
 
3
+ import weakref
4
+ from contextlib import AsyncExitStack
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import anyio
8
+ import mcp.types
9
+ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
3
10
  from mcp.server.lowlevel.server import (
4
11
  LifespanResultT,
5
12
  NotificationOptions,
@@ -9,11 +16,82 @@ from mcp.server.lowlevel.server import (
9
16
  Server as _Server,
10
17
  )
11
18
  from mcp.server.models import InitializationOptions
19
+ from mcp.server.session import ServerSession
20
+ from mcp.server.stdio import stdio_server as stdio_server
21
+ from mcp.shared.message import SessionMessage
22
+ from mcp.shared.session import RequestResponder
23
+
24
+ from fastmcp.utilities.logging import get_logger
25
+
26
+ if TYPE_CHECKING:
27
+ from fastmcp.server.server import FastMCP
28
+
29
+ logger = get_logger(__name__)
30
+
31
+
32
+ class MiddlewareServerSession(ServerSession):
33
+ """ServerSession that routes initialization requests through FastMCP middleware."""
34
+
35
+ def __init__(self, fastmcp: FastMCP, *args, **kwargs):
36
+ super().__init__(*args, **kwargs)
37
+ self._fastmcp_ref: weakref.ref[FastMCP] = weakref.ref(fastmcp)
38
+
39
+ @property
40
+ def fastmcp(self) -> FastMCP:
41
+ """Get the FastMCP instance."""
42
+ fastmcp = self._fastmcp_ref()
43
+ if fastmcp is None:
44
+ raise RuntimeError("FastMCP instance is no longer available")
45
+ return fastmcp
46
+
47
+ async def _received_request(
48
+ self,
49
+ responder: RequestResponder[mcp.types.ClientRequest, mcp.types.ServerResult],
50
+ ):
51
+ """
52
+ Override the _received_request method to route initialization requests
53
+ through FastMCP middleware.
54
+
55
+ These are not handled by routes that FastMCP typically overrides and
56
+ require special handling.
57
+ """
58
+ import fastmcp.server.context
59
+ from fastmcp.server.middleware.middleware import MiddlewareContext
60
+
61
+ if isinstance(responder.request.root, mcp.types.InitializeRequest):
62
+
63
+ async def call_original_handler(
64
+ ctx: MiddlewareContext,
65
+ ) -> None:
66
+ return await super(MiddlewareServerSession, self)._received_request(
67
+ responder
68
+ )
69
+
70
+ async with fastmcp.server.context.Context(
71
+ fastmcp=self.fastmcp
72
+ ) as fastmcp_ctx:
73
+ # Create the middleware context.
74
+ mw_context = MiddlewareContext(
75
+ message=responder.request.root,
76
+ source="client",
77
+ type="request",
78
+ method="initialize",
79
+ fastmcp_context=fastmcp_ctx,
80
+ )
81
+
82
+ return await self.fastmcp._apply_middleware(
83
+ mw_context, call_original_handler
84
+ )
85
+ else:
86
+ return await super()._received_request(responder)
12
87
 
13
88
 
14
89
  class LowLevelServer(_Server[LifespanResultT, RequestT]):
15
- def __init__(self, *args, **kwargs):
90
+ def __init__(self, fastmcp: FastMCP, *args: Any, **kwargs: Any):
16
91
  super().__init__(*args, **kwargs)
92
+ # Store a weak reference to FastMCP to avoid circular references
93
+ self._fastmcp_ref: weakref.ref[FastMCP] = weakref.ref(fastmcp)
94
+
17
95
  # FastMCP servers support notifications for all components
18
96
  self.notification_options = NotificationOptions(
19
97
  prompts_changed=True,
@@ -21,6 +99,14 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]):
21
99
  tools_changed=True,
22
100
  )
23
101
 
102
+ @property
103
+ def fastmcp(self) -> FastMCP:
104
+ """Get the FastMCP instance."""
105
+ fastmcp = self._fastmcp_ref()
106
+ if fastmcp is None:
107
+ raise RuntimeError("FastMCP instance is no longer available")
108
+ return fastmcp
109
+
24
110
  def create_initialization_options(
25
111
  self,
26
112
  notification_options: NotificationOptions | None = None,
@@ -35,3 +121,36 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]):
35
121
  experimental_capabilities=experimental_capabilities,
36
122
  **kwargs,
37
123
  )
124
+
125
+ async def run(
126
+ self,
127
+ read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
128
+ write_stream: MemoryObjectSendStream[SessionMessage],
129
+ initialization_options: InitializationOptions,
130
+ raise_exceptions: bool = False,
131
+ stateless: bool = False,
132
+ ):
133
+ """
134
+ Overrides the run method to use the MiddlewareServerSession.
135
+ """
136
+ async with AsyncExitStack() as stack:
137
+ lifespan_context = await stack.enter_async_context(self.lifespan(self))
138
+ session = await stack.enter_async_context(
139
+ MiddlewareServerSession(
140
+ self.fastmcp,
141
+ read_stream,
142
+ write_stream,
143
+ initialization_options,
144
+ stateless=stateless,
145
+ )
146
+ )
147
+
148
+ async with anyio.create_task_group() as tg:
149
+ async for message in session.incoming_messages:
150
+ tg.start_soon(
151
+ self._handle_message,
152
+ message,
153
+ session,
154
+ lifespan_context,
155
+ raise_exceptions,
156
+ )
@@ -5,7 +5,7 @@ from .middleware import (
5
5
  )
6
6
 
7
7
  __all__ = [
8
+ "CallNext",
8
9
  "Middleware",
9
10
  "MiddlewareContext",
10
- "CallNext",
11
11
  ]
@@ -0,0 +1,476 @@
1
+ """A middleware for response caching."""
2
+
3
+ from collections.abc import Sequence
4
+ from logging import Logger
5
+ from typing import Any, TypedDict
6
+
7
+ import mcp.types
8
+ import pydantic_core
9
+ from key_value.aio.adapters.pydantic import PydanticAdapter
10
+ from key_value.aio.protocols.key_value import AsyncKeyValue
11
+ from key_value.aio.stores.memory import MemoryStore
12
+ from key_value.aio.wrappers.limit_size import LimitSizeWrapper
13
+ from key_value.aio.wrappers.statistics import StatisticsWrapper
14
+ from key_value.aio.wrappers.statistics.wrapper import (
15
+ KVStoreCollectionStatistics,
16
+ )
17
+ from mcp.server.lowlevel.helper_types import ReadResourceContents
18
+ from pydantic import BaseModel, Field
19
+ from typing_extensions import NotRequired, Self, override
20
+
21
+ from fastmcp.prompts.prompt import Prompt
22
+ from fastmcp.resources.resource import Resource
23
+ from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
24
+ from fastmcp.tools.tool import Tool, ToolResult
25
+ from fastmcp.utilities.logging import get_logger
26
+
27
+ logger: Logger = get_logger(name=__name__)
28
+
29
+ # Constants
30
+ ONE_HOUR_IN_SECONDS = 3600
31
+ FIVE_MINUTES_IN_SECONDS = 300
32
+
33
+ ONE_MB_IN_BYTES = 1024 * 1024
34
+
35
+ GLOBAL_KEY = "__global__"
36
+
37
+
38
+ class CachableReadResourceContents(BaseModel):
39
+ """A wrapper for ReadResourceContents that can be cached."""
40
+
41
+ content: str | bytes
42
+ mime_type: str | None = None
43
+
44
+ def get_size(self) -> int:
45
+ return len(self.model_dump_json())
46
+
47
+ @classmethod
48
+ def get_sizes(cls, values: Sequence[Self]) -> int:
49
+ return sum(item.get_size() for item in values)
50
+
51
+ @classmethod
52
+ def wrap(cls, values: Sequence[ReadResourceContents]) -> list[Self]:
53
+ return [cls(content=item.content, mime_type=item.mime_type) for item in values]
54
+
55
+ @classmethod
56
+ def unwrap(cls, values: Sequence[Self]) -> list[ReadResourceContents]:
57
+ return [
58
+ ReadResourceContents(content=item.content, mime_type=item.mime_type)
59
+ for item in values
60
+ ]
61
+
62
+
63
+ class CachableToolResult(BaseModel):
64
+ content: list[mcp.types.ContentBlock]
65
+ structured_content: dict[str, Any] | None
66
+ meta: dict[str, Any] | None
67
+
68
+ @classmethod
69
+ def wrap(cls, value: ToolResult) -> Self:
70
+ return cls(
71
+ content=value.content,
72
+ structured_content=value.structured_content,
73
+ meta=value.meta,
74
+ )
75
+
76
+ def unwrap(self) -> ToolResult:
77
+ return ToolResult(
78
+ content=self.content,
79
+ structured_content=self.structured_content,
80
+ meta=self.meta,
81
+ )
82
+
83
+
84
+ class SharedMethodSettings(TypedDict):
85
+ """Shared config for a cache method."""
86
+
87
+ ttl: NotRequired[int]
88
+ enabled: NotRequired[bool]
89
+
90
+
91
+ class ListToolsSettings(SharedMethodSettings):
92
+ """Configuration options for Tool-related caching."""
93
+
94
+
95
+ class ListResourcesSettings(SharedMethodSettings):
96
+ """Configuration options for Resource-related caching."""
97
+
98
+
99
+ class ListPromptsSettings(SharedMethodSettings):
100
+ """Configuration options for Prompt-related caching."""
101
+
102
+
103
+ class CallToolSettings(SharedMethodSettings):
104
+ """Configuration options for Tool-related caching."""
105
+
106
+ included_tools: NotRequired[list[str]]
107
+ excluded_tools: NotRequired[list[str]]
108
+
109
+
110
+ class ReadResourceSettings(SharedMethodSettings):
111
+ """Configuration options for Resource-related caching."""
112
+
113
+
114
+ class GetPromptSettings(SharedMethodSettings):
115
+ """Configuration options for Prompt-related caching."""
116
+
117
+
118
+ class ResponseCachingStatistics(BaseModel):
119
+ list_tools: KVStoreCollectionStatistics | None = Field(default=None)
120
+ list_resources: KVStoreCollectionStatistics | None = Field(default=None)
121
+ list_prompts: KVStoreCollectionStatistics | None = Field(default=None)
122
+ read_resource: KVStoreCollectionStatistics | None = Field(default=None)
123
+ get_prompt: KVStoreCollectionStatistics | None = Field(default=None)
124
+ call_tool: KVStoreCollectionStatistics | None = Field(default=None)
125
+
126
+
127
+ class ResponseCachingMiddleware(Middleware):
128
+ """The response caching middleware offers a simple way to cache responses to mcp methods. The Middleware
129
+ supports cache invalidation via notifications from the server. The Middleware implements TTL-based caching
130
+ but cache implementations may offer additional features like LRU eviction, size limits, and more.
131
+
132
+ When items are retrieved from the cache they will no longer be the original objects, but rather no-op objects
133
+ this means that response caching may not be compatible with other middleware that expects original subclasses.
134
+
135
+ Notes:
136
+ - Caches `tools/call`, `resources/read`, `prompts/get`, `tools/list`, `resources/list`, and `prompts/list` requests.
137
+ - Cache keys are derived from method name and arguments.
138
+ """
139
+
140
+ def __init__(
141
+ self,
142
+ cache_storage: AsyncKeyValue | None = None,
143
+ list_tools_settings: ListToolsSettings | None = None,
144
+ list_resources_settings: ListResourcesSettings | None = None,
145
+ list_prompts_settings: ListPromptsSettings | None = None,
146
+ read_resource_settings: ReadResourceSettings | None = None,
147
+ get_prompt_settings: GetPromptSettings | None = None,
148
+ call_tool_settings: CallToolSettings | None = None,
149
+ max_item_size: int = ONE_MB_IN_BYTES,
150
+ ):
151
+ """Initialize the response caching middleware.
152
+
153
+ Args:
154
+ cache_storage: The cache backend to use. If None, an in-memory cache is used.
155
+ list_tools_settings: The settings for the list tools method. If None, the default settings are used (5 minute TTL).
156
+ list_resources_settings: The settings for the list resources method. If None, the default settings are used (5 minute TTL).
157
+ list_prompts_settings: The settings for the list prompts method. If None, the default settings are used (5 minute TTL).
158
+ read_resource_settings: The settings for the read resource method. If None, the default settings are used (1 hour TTL).
159
+ get_prompt_settings: The settings for the get prompt method. If None, the default settings are used (1 hour TTL).
160
+ call_tool_settings: The settings for the call tool method. If None, the default settings are used (1 hour TTL).
161
+ max_item_size: The maximum size of items eligible for caching. Defaults to 1MB.
162
+ """
163
+
164
+ self._backend: AsyncKeyValue = cache_storage or MemoryStore()
165
+
166
+ # When the size limit is exceeded, the put will silently fail
167
+ self._size_limiter: LimitSizeWrapper = LimitSizeWrapper(
168
+ key_value=self._backend, max_size=max_item_size, raise_on_too_large=False
169
+ )
170
+ self._stats: StatisticsWrapper = StatisticsWrapper(key_value=self._size_limiter)
171
+
172
+ self._list_tools_settings: ListToolsSettings = (
173
+ list_tools_settings or ListToolsSettings()
174
+ )
175
+ self._list_resources_settings: ListResourcesSettings = (
176
+ list_resources_settings or ListResourcesSettings()
177
+ )
178
+ self._list_prompts_settings: ListPromptsSettings = (
179
+ list_prompts_settings or ListPromptsSettings()
180
+ )
181
+
182
+ self._read_resource_settings: ReadResourceSettings = (
183
+ read_resource_settings or ReadResourceSettings()
184
+ )
185
+ self._get_prompt_settings: GetPromptSettings = (
186
+ get_prompt_settings or GetPromptSettings()
187
+ )
188
+ self._call_tool_settings: CallToolSettings = (
189
+ call_tool_settings or CallToolSettings()
190
+ )
191
+
192
+ self._list_tools_cache: PydanticAdapter[list[Tool]] = PydanticAdapter(
193
+ key_value=self._stats,
194
+ pydantic_model=list[Tool],
195
+ default_collection="tools/list",
196
+ )
197
+
198
+ self._list_resources_cache: PydanticAdapter[list[Resource]] = PydanticAdapter(
199
+ key_value=self._stats,
200
+ pydantic_model=list[Resource],
201
+ default_collection="resources/list",
202
+ )
203
+
204
+ self._list_prompts_cache: PydanticAdapter[list[Prompt]] = PydanticAdapter(
205
+ key_value=self._stats,
206
+ pydantic_model=list[Prompt],
207
+ default_collection="prompts/list",
208
+ )
209
+
210
+ self._read_resource_cache: PydanticAdapter[
211
+ list[CachableReadResourceContents]
212
+ ] = PydanticAdapter(
213
+ key_value=self._stats,
214
+ pydantic_model=list[CachableReadResourceContents],
215
+ default_collection="resources/read",
216
+ )
217
+
218
+ self._get_prompt_cache: PydanticAdapter[mcp.types.GetPromptResult] = (
219
+ PydanticAdapter(
220
+ key_value=self._stats,
221
+ pydantic_model=mcp.types.GetPromptResult,
222
+ default_collection="prompts/get",
223
+ )
224
+ )
225
+
226
+ self._call_tool_cache: PydanticAdapter[CachableToolResult] = PydanticAdapter(
227
+ key_value=self._stats,
228
+ pydantic_model=CachableToolResult,
229
+ default_collection="tools/call",
230
+ )
231
+
232
+ @override
233
+ async def on_list_tools(
234
+ self,
235
+ context: MiddlewareContext[mcp.types.ListToolsRequest],
236
+ call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]],
237
+ ) -> Sequence[Tool]:
238
+ """List tools from the cache, if caching is enabled, and the result is in the cache. Otherwise,
239
+ otherwise call the next middleware and store the result in the cache if caching is enabled."""
240
+ if self._list_tools_settings.get("enabled") is False:
241
+ return await call_next(context)
242
+
243
+ if cached_value := await self._list_tools_cache.get(key=GLOBAL_KEY):
244
+ return cached_value
245
+
246
+ tools: Sequence[Tool] = await call_next(context=context)
247
+
248
+ # Turn any subclass of Tool into a Tool
249
+ cachable_tools: list[Tool] = [
250
+ Tool(
251
+ name=tool.name,
252
+ title=tool.title,
253
+ description=tool.description,
254
+ parameters=tool.parameters,
255
+ output_schema=tool.output_schema,
256
+ annotations=tool.annotations,
257
+ meta=tool.meta,
258
+ tags=tool.tags,
259
+ enabled=tool.enabled,
260
+ )
261
+ for tool in tools
262
+ ]
263
+
264
+ await self._list_tools_cache.put(
265
+ key=GLOBAL_KEY,
266
+ value=cachable_tools,
267
+ ttl=self._list_tools_settings.get("ttl", FIVE_MINUTES_IN_SECONDS),
268
+ )
269
+
270
+ return cachable_tools
271
+
272
+ @override
273
+ async def on_list_resources(
274
+ self,
275
+ context: MiddlewareContext[mcp.types.ListResourcesRequest],
276
+ call_next: CallNext[mcp.types.ListResourcesRequest, Sequence[Resource]],
277
+ ) -> Sequence[Resource]:
278
+ """List resources from the cache, if caching is enabled, and the result is in the cache. Otherwise,
279
+ otherwise call the next middleware and store the result in the cache if caching is enabled."""
280
+ if self._list_resources_settings.get("enabled") is False:
281
+ return await call_next(context)
282
+
283
+ if cached_value := await self._list_resources_cache.get(key=GLOBAL_KEY):
284
+ return cached_value
285
+
286
+ resources: Sequence[Resource] = await call_next(context=context)
287
+
288
+ # Turn any subclass of Resource into a Resource
289
+ cachable_resources: list[Resource] = [
290
+ Resource(
291
+ name=resource.name,
292
+ title=resource.title,
293
+ description=resource.description,
294
+ tags=resource.tags,
295
+ meta=resource.meta,
296
+ mime_type=resource.mime_type,
297
+ annotations=resource.annotations,
298
+ enabled=resource.enabled,
299
+ uri=resource.uri,
300
+ )
301
+ for resource in resources
302
+ ]
303
+
304
+ await self._list_resources_cache.put(
305
+ key=GLOBAL_KEY,
306
+ value=cachable_resources,
307
+ ttl=self._list_resources_settings.get("ttl", FIVE_MINUTES_IN_SECONDS),
308
+ )
309
+
310
+ return cachable_resources
311
+
312
+ @override
313
+ async def on_list_prompts(
314
+ self,
315
+ context: MiddlewareContext[mcp.types.ListPromptsRequest],
316
+ call_next: CallNext[mcp.types.ListPromptsRequest, Sequence[Prompt]],
317
+ ) -> Sequence[Prompt]:
318
+ """List prompts from the cache, if caching is enabled, and the result is in the cache. Otherwise,
319
+ otherwise call the next middleware and store the result in the cache if caching is enabled."""
320
+ if self._list_prompts_settings.get("enabled") is False:
321
+ return await call_next(context)
322
+
323
+ if cached_value := await self._list_prompts_cache.get(key=GLOBAL_KEY):
324
+ return cached_value
325
+
326
+ prompts: Sequence[Prompt] = await call_next(context=context)
327
+
328
+ # Turn any subclass of Prompt into a Prompt
329
+ cachable_prompts: list[Prompt] = [
330
+ Prompt(
331
+ name=prompt.name,
332
+ title=prompt.title,
333
+ description=prompt.description,
334
+ tags=prompt.tags,
335
+ meta=prompt.meta,
336
+ enabled=prompt.enabled,
337
+ arguments=prompt.arguments,
338
+ )
339
+ for prompt in prompts
340
+ ]
341
+
342
+ await self._list_prompts_cache.put(
343
+ key=GLOBAL_KEY,
344
+ value=cachable_prompts,
345
+ ttl=self._list_prompts_settings.get("ttl", FIVE_MINUTES_IN_SECONDS),
346
+ )
347
+
348
+ return cachable_prompts
349
+
350
+ @override
351
+ async def on_call_tool(
352
+ self,
353
+ context: MiddlewareContext[mcp.types.CallToolRequestParams],
354
+ call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult],
355
+ ) -> ToolResult:
356
+ """Call a tool from the cache, if caching is enabled, and the result is in the cache. Otherwise,
357
+ otherwise call the next middleware and store the result in the cache if caching is enabled."""
358
+ tool_name = context.message.name
359
+
360
+ if self._call_tool_settings.get(
361
+ "enabled"
362
+ ) is False or not self._matches_tool_cache_settings(tool_name=tool_name):
363
+ return await call_next(context=context)
364
+
365
+ cache_key: str = f"{tool_name}:{_get_arguments_str(context.message.arguments)}"
366
+
367
+ if cached_value := await self._call_tool_cache.get(key=cache_key):
368
+ return cached_value.unwrap()
369
+
370
+ tool_result: ToolResult = await call_next(context=context)
371
+ cachable_tool_result: CachableToolResult = CachableToolResult.wrap(
372
+ value=tool_result
373
+ )
374
+
375
+ await self._call_tool_cache.put(
376
+ key=cache_key,
377
+ value=cachable_tool_result,
378
+ ttl=self._call_tool_settings.get("ttl", ONE_HOUR_IN_SECONDS),
379
+ )
380
+
381
+ return cachable_tool_result.unwrap()
382
+
383
+ @override
384
+ async def on_read_resource(
385
+ self,
386
+ context: MiddlewareContext[mcp.types.ReadResourceRequestParams],
387
+ call_next: CallNext[
388
+ mcp.types.ReadResourceRequestParams, Sequence[ReadResourceContents]
389
+ ],
390
+ ) -> Sequence[ReadResourceContents]:
391
+ """Read a resource from the cache, if caching is enabled, and the result is in the cache. Otherwise,
392
+ otherwise call the next middleware and store the result in the cache if caching is enabled."""
393
+ if self._read_resource_settings.get("enabled") is False:
394
+ return await call_next(context=context)
395
+
396
+ cache_key: str = str(context.message.uri)
397
+ cached_value: list[CachableReadResourceContents] | None
398
+
399
+ if cached_value := await self._read_resource_cache.get(key=cache_key):
400
+ return CachableReadResourceContents.unwrap(values=cached_value)
401
+
402
+ value: Sequence[ReadResourceContents] = await call_next(context=context)
403
+ cached_value = CachableReadResourceContents.wrap(values=value)
404
+
405
+ await self._read_resource_cache.put(
406
+ key=cache_key,
407
+ value=cached_value,
408
+ ttl=self._read_resource_settings.get("ttl", ONE_HOUR_IN_SECONDS),
409
+ )
410
+
411
+ return CachableReadResourceContents.unwrap(values=cached_value)
412
+
413
+ @override
414
+ async def on_get_prompt(
415
+ self,
416
+ context: MiddlewareContext[mcp.types.GetPromptRequestParams],
417
+ call_next: CallNext[
418
+ mcp.types.GetPromptRequestParams, mcp.types.GetPromptResult
419
+ ],
420
+ ) -> mcp.types.GetPromptResult:
421
+ """Get a prompt from the cache, if caching is enabled, and the result is in the cache. Otherwise,
422
+ otherwise call the next middleware and store the result in the cache if caching is enabled."""
423
+ if self._get_prompt_settings.get("enabled") is False:
424
+ return await call_next(context=context)
425
+
426
+ cache_key: str = f"{context.message.name}:{_get_arguments_str(arguments=context.message.arguments)}"
427
+
428
+ if cached_value := await self._get_prompt_cache.get(key=cache_key):
429
+ return cached_value
430
+
431
+ value: mcp.types.GetPromptResult = await call_next(context=context)
432
+
433
+ await self._get_prompt_cache.put(
434
+ key=cache_key,
435
+ value=value,
436
+ ttl=self._get_prompt_settings.get("ttl", ONE_HOUR_IN_SECONDS),
437
+ )
438
+
439
+ return value
440
+
441
+ def _matches_tool_cache_settings(self, tool_name: str) -> bool:
442
+ """Check if the tool matches the cache settings for tool calls."""
443
+
444
+ if included_tools := self._call_tool_settings.get("included_tools"):
445
+ if tool_name not in included_tools:
446
+ return False
447
+
448
+ if excluded_tools := self._call_tool_settings.get("excluded_tools"):
449
+ if tool_name in excluded_tools:
450
+ return False
451
+
452
+ return True
453
+
454
+ def statistics(self) -> ResponseCachingStatistics:
455
+ """Get the statistics for the cache."""
456
+ return ResponseCachingStatistics(
457
+ list_tools=self._stats.statistics.collections.get("tools/list"),
458
+ list_resources=self._stats.statistics.collections.get("resources/list"),
459
+ list_prompts=self._stats.statistics.collections.get("prompts/list"),
460
+ read_resource=self._stats.statistics.collections.get("resources/read"),
461
+ get_prompt=self._stats.statistics.collections.get("prompts/get"),
462
+ call_tool=self._stats.statistics.collections.get("tools/call"),
463
+ )
464
+
465
+
466
+ def _get_arguments_str(arguments: dict[str, Any] | None) -> str:
467
+ """Get a string representation of the arguments."""
468
+
469
+ if arguments is None:
470
+ return "null"
471
+
472
+ try:
473
+ return pydantic_core.to_json(value=arguments, fallback=str).decode()
474
+
475
+ except TypeError:
476
+ return repr(arguments)