fastmcp 2.12.5__py3-none-any.whl → 2.13.0__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.
- fastmcp/cli/cli.py +7 -6
- fastmcp/cli/install/claude_code.py +6 -6
- fastmcp/cli/install/claude_desktop.py +3 -3
- fastmcp/cli/install/cursor.py +7 -7
- fastmcp/cli/install/gemini_cli.py +3 -3
- fastmcp/cli/install/mcp_json.py +3 -3
- fastmcp/cli/run.py +13 -8
- fastmcp/client/auth/oauth.py +100 -208
- fastmcp/client/client.py +11 -11
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/transports.py +77 -22
- fastmcp/contrib/component_manager/component_service.py +6 -6
- fastmcp/contrib/mcp_mixin/README.md +32 -1
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +4 -0
- fastmcp/experimental/utilities/openapi/parser.py +23 -3
- fastmcp/prompts/prompt.py +13 -6
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/resource.py +13 -6
- fastmcp/resources/resource_manager.py +5 -164
- fastmcp/resources/template.py +107 -17
- fastmcp/resources/types.py +30 -24
- fastmcp/server/auth/auth.py +40 -32
- fastmcp/server/auth/handlers/authorize.py +324 -0
- fastmcp/server/auth/jwt_issuer.py +236 -0
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +1256 -242
- fastmcp/server/auth/oidc_proxy.py +23 -6
- fastmcp/server/auth/providers/auth0.py +40 -21
- fastmcp/server/auth/providers/aws.py +29 -3
- fastmcp/server/auth/providers/azure.py +178 -127
- fastmcp/server/auth/providers/descope.py +4 -6
- fastmcp/server/auth/providers/github.py +29 -8
- fastmcp/server/auth/providers/google.py +30 -9
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +8 -2
- fastmcp/server/auth/providers/scalekit.py +179 -0
- fastmcp/server/auth/providers/supabase.py +172 -0
- fastmcp/server/auth/providers/workos.py +32 -14
- fastmcp/server/context.py +122 -36
- fastmcp/server/http.py +58 -18
- fastmcp/server/low_level.py +121 -2
- fastmcp/server/middleware/caching.py +469 -0
- fastmcp/server/middleware/error_handling.py +6 -2
- fastmcp/server/middleware/logging.py +48 -37
- fastmcp/server/middleware/middleware.py +28 -15
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/proxy.py +6 -6
- fastmcp/server/server.py +683 -207
- fastmcp/settings.py +24 -10
- fastmcp/tools/tool.py +7 -3
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +3 -3
- fastmcp/utilities/cli.py +62 -22
- fastmcp/utilities/components.py +5 -0
- fastmcp/utilities/inspect.py +77 -21
- fastmcp/utilities/logging.py +118 -8
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
- fastmcp/utilities/tests.py +87 -4
- fastmcp/utilities/types.py +1 -1
- fastmcp/utilities/ui.py +617 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/METADATA +10 -6
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/RECORD +70 -63
- fastmcp/cli/claude.py +0 -135
- fastmcp/utilities/storage.py +0 -204
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,469 @@
|
|
|
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
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def wrap(cls, value: ToolResult) -> Self:
|
|
69
|
+
return cls(content=value.content, structured_content=value.structured_content)
|
|
70
|
+
|
|
71
|
+
def unwrap(self) -> ToolResult:
|
|
72
|
+
return ToolResult(
|
|
73
|
+
content=self.content, structured_content=self.structured_content
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SharedMethodSettings(TypedDict):
|
|
78
|
+
"""Shared config for a cache method."""
|
|
79
|
+
|
|
80
|
+
ttl: NotRequired[int]
|
|
81
|
+
enabled: NotRequired[bool]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ListToolsSettings(SharedMethodSettings):
|
|
85
|
+
"""Configuration options for Tool-related caching."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ListResourcesSettings(SharedMethodSettings):
|
|
89
|
+
"""Configuration options for Resource-related caching."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ListPromptsSettings(SharedMethodSettings):
|
|
93
|
+
"""Configuration options for Prompt-related caching."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CallToolSettings(SharedMethodSettings):
|
|
97
|
+
"""Configuration options for Tool-related caching."""
|
|
98
|
+
|
|
99
|
+
included_tools: NotRequired[list[str]]
|
|
100
|
+
excluded_tools: NotRequired[list[str]]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ReadResourceSettings(SharedMethodSettings):
|
|
104
|
+
"""Configuration options for Resource-related caching."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class GetPromptSettings(SharedMethodSettings):
|
|
108
|
+
"""Configuration options for Prompt-related caching."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ResponseCachingStatistics(BaseModel):
|
|
112
|
+
list_tools: KVStoreCollectionStatistics | None = Field(default=None)
|
|
113
|
+
list_resources: KVStoreCollectionStatistics | None = Field(default=None)
|
|
114
|
+
list_prompts: KVStoreCollectionStatistics | None = Field(default=None)
|
|
115
|
+
read_resource: KVStoreCollectionStatistics | None = Field(default=None)
|
|
116
|
+
get_prompt: KVStoreCollectionStatistics | None = Field(default=None)
|
|
117
|
+
call_tool: KVStoreCollectionStatistics | None = Field(default=None)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ResponseCachingMiddleware(Middleware):
|
|
121
|
+
"""The response caching middleware offers a simple way to cache responses to mcp methods. The Middleware
|
|
122
|
+
supports cache invalidation via notifications from the server. The Middleware implements TTL-based caching
|
|
123
|
+
but cache implementations may offer additional features like LRU eviction, size limits, and more.
|
|
124
|
+
|
|
125
|
+
When items are retrieved from the cache they will no longer be the original objects, but rather no-op objects
|
|
126
|
+
this means that response caching may not be compatible with other middleware that expects original subclasses.
|
|
127
|
+
|
|
128
|
+
Notes:
|
|
129
|
+
- Caches `tools/call`, `resources/read`, `prompts/get`, `tools/list`, `resources/list`, and `prompts/list` requests.
|
|
130
|
+
- Cache keys are derived from method name and arguments.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
cache_storage: AsyncKeyValue | None = None,
|
|
136
|
+
list_tools_settings: ListToolsSettings | None = None,
|
|
137
|
+
list_resources_settings: ListResourcesSettings | None = None,
|
|
138
|
+
list_prompts_settings: ListPromptsSettings | None = None,
|
|
139
|
+
read_resource_settings: ReadResourceSettings | None = None,
|
|
140
|
+
get_prompt_settings: GetPromptSettings | None = None,
|
|
141
|
+
call_tool_settings: CallToolSettings | None = None,
|
|
142
|
+
max_item_size: int = ONE_MB_IN_BYTES,
|
|
143
|
+
):
|
|
144
|
+
"""Initialize the response caching middleware.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
cache_storage: The cache backend to use. If None, an in-memory cache is used.
|
|
148
|
+
list_tools_settings: The settings for the list tools method. If None, the default settings are used (5 minute TTL).
|
|
149
|
+
list_resources_settings: The settings for the list resources method. If None, the default settings are used (5 minute TTL).
|
|
150
|
+
list_prompts_settings: The settings for the list prompts method. If None, the default settings are used (5 minute TTL).
|
|
151
|
+
read_resource_settings: The settings for the read resource method. If None, the default settings are used (1 hour TTL).
|
|
152
|
+
get_prompt_settings: The settings for the get prompt method. If None, the default settings are used (1 hour TTL).
|
|
153
|
+
call_tool_settings: The settings for the call tool method. If None, the default settings are used (1 hour TTL).
|
|
154
|
+
max_item_size: The maximum size of items eligible for caching. Defaults to 1MB.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
self._backend: AsyncKeyValue = cache_storage or MemoryStore()
|
|
158
|
+
|
|
159
|
+
# When the size limit is exceeded, the put will silently fail
|
|
160
|
+
self._size_limiter: LimitSizeWrapper = LimitSizeWrapper(
|
|
161
|
+
key_value=self._backend, max_size=max_item_size, raise_on_too_large=False
|
|
162
|
+
)
|
|
163
|
+
self._stats: StatisticsWrapper = StatisticsWrapper(key_value=self._size_limiter)
|
|
164
|
+
|
|
165
|
+
self._list_tools_settings: ListToolsSettings = (
|
|
166
|
+
list_tools_settings or ListToolsSettings()
|
|
167
|
+
)
|
|
168
|
+
self._list_resources_settings: ListResourcesSettings = (
|
|
169
|
+
list_resources_settings or ListResourcesSettings()
|
|
170
|
+
)
|
|
171
|
+
self._list_prompts_settings: ListPromptsSettings = (
|
|
172
|
+
list_prompts_settings or ListPromptsSettings()
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
self._read_resource_settings: ReadResourceSettings = (
|
|
176
|
+
read_resource_settings or ReadResourceSettings()
|
|
177
|
+
)
|
|
178
|
+
self._get_prompt_settings: GetPromptSettings = (
|
|
179
|
+
get_prompt_settings or GetPromptSettings()
|
|
180
|
+
)
|
|
181
|
+
self._call_tool_settings: CallToolSettings = (
|
|
182
|
+
call_tool_settings or CallToolSettings()
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
self._list_tools_cache: PydanticAdapter[list[Tool]] = PydanticAdapter(
|
|
186
|
+
key_value=self._stats,
|
|
187
|
+
pydantic_model=list[Tool],
|
|
188
|
+
default_collection="tools/list",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
self._list_resources_cache: PydanticAdapter[list[Resource]] = PydanticAdapter(
|
|
192
|
+
key_value=self._stats,
|
|
193
|
+
pydantic_model=list[Resource],
|
|
194
|
+
default_collection="resources/list",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
self._list_prompts_cache: PydanticAdapter[list[Prompt]] = PydanticAdapter(
|
|
198
|
+
key_value=self._stats,
|
|
199
|
+
pydantic_model=list[Prompt],
|
|
200
|
+
default_collection="prompts/list",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
self._read_resource_cache: PydanticAdapter[
|
|
204
|
+
list[CachableReadResourceContents]
|
|
205
|
+
] = PydanticAdapter(
|
|
206
|
+
key_value=self._stats,
|
|
207
|
+
pydantic_model=list[CachableReadResourceContents],
|
|
208
|
+
default_collection="resources/read",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
self._get_prompt_cache: PydanticAdapter[mcp.types.GetPromptResult] = (
|
|
212
|
+
PydanticAdapter(
|
|
213
|
+
key_value=self._stats,
|
|
214
|
+
pydantic_model=mcp.types.GetPromptResult,
|
|
215
|
+
default_collection="prompts/get",
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
self._call_tool_cache: PydanticAdapter[CachableToolResult] = PydanticAdapter(
|
|
220
|
+
key_value=self._stats,
|
|
221
|
+
pydantic_model=CachableToolResult,
|
|
222
|
+
default_collection="tools/call",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
@override
|
|
226
|
+
async def on_list_tools(
|
|
227
|
+
self,
|
|
228
|
+
context: MiddlewareContext[mcp.types.ListToolsRequest],
|
|
229
|
+
call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]],
|
|
230
|
+
) -> Sequence[Tool]:
|
|
231
|
+
"""List tools from the cache, if caching is enabled, and the result is in the cache. Otherwise,
|
|
232
|
+
otherwise call the next middleware and store the result in the cache if caching is enabled."""
|
|
233
|
+
if self._list_tools_settings.get("enabled") is False:
|
|
234
|
+
return await call_next(context)
|
|
235
|
+
|
|
236
|
+
if cached_value := await self._list_tools_cache.get(key=GLOBAL_KEY):
|
|
237
|
+
return cached_value
|
|
238
|
+
|
|
239
|
+
tools: Sequence[Tool] = await call_next(context=context)
|
|
240
|
+
|
|
241
|
+
# Turn any subclass of Tool into a Tool
|
|
242
|
+
cachable_tools: list[Tool] = [
|
|
243
|
+
Tool(
|
|
244
|
+
name=tool.name,
|
|
245
|
+
title=tool.title,
|
|
246
|
+
description=tool.description,
|
|
247
|
+
parameters=tool.parameters,
|
|
248
|
+
output_schema=tool.output_schema,
|
|
249
|
+
annotations=tool.annotations,
|
|
250
|
+
meta=tool.meta,
|
|
251
|
+
tags=tool.tags,
|
|
252
|
+
enabled=tool.enabled,
|
|
253
|
+
)
|
|
254
|
+
for tool in tools
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
await self._list_tools_cache.put(
|
|
258
|
+
key=GLOBAL_KEY,
|
|
259
|
+
value=cachable_tools,
|
|
260
|
+
ttl=self._list_tools_settings.get("ttl", FIVE_MINUTES_IN_SECONDS),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return cachable_tools
|
|
264
|
+
|
|
265
|
+
@override
|
|
266
|
+
async def on_list_resources(
|
|
267
|
+
self,
|
|
268
|
+
context: MiddlewareContext[mcp.types.ListResourcesRequest],
|
|
269
|
+
call_next: CallNext[mcp.types.ListResourcesRequest, Sequence[Resource]],
|
|
270
|
+
) -> Sequence[Resource]:
|
|
271
|
+
"""List resources from the cache, if caching is enabled, and the result is in the cache. Otherwise,
|
|
272
|
+
otherwise call the next middleware and store the result in the cache if caching is enabled."""
|
|
273
|
+
if self._list_resources_settings.get("enabled") is False:
|
|
274
|
+
return await call_next(context)
|
|
275
|
+
|
|
276
|
+
if cached_value := await self._list_resources_cache.get(key=GLOBAL_KEY):
|
|
277
|
+
return cached_value
|
|
278
|
+
|
|
279
|
+
resources: Sequence[Resource] = await call_next(context=context)
|
|
280
|
+
|
|
281
|
+
# Turn any subclass of Resource into a Resource
|
|
282
|
+
cachable_resources: list[Resource] = [
|
|
283
|
+
Resource(
|
|
284
|
+
name=resource.name,
|
|
285
|
+
title=resource.title,
|
|
286
|
+
description=resource.description,
|
|
287
|
+
tags=resource.tags,
|
|
288
|
+
meta=resource.meta,
|
|
289
|
+
mime_type=resource.mime_type,
|
|
290
|
+
annotations=resource.annotations,
|
|
291
|
+
enabled=resource.enabled,
|
|
292
|
+
uri=resource.uri,
|
|
293
|
+
)
|
|
294
|
+
for resource in resources
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
await self._list_resources_cache.put(
|
|
298
|
+
key=GLOBAL_KEY,
|
|
299
|
+
value=cachable_resources,
|
|
300
|
+
ttl=self._list_resources_settings.get("ttl", FIVE_MINUTES_IN_SECONDS),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
return cachable_resources
|
|
304
|
+
|
|
305
|
+
@override
|
|
306
|
+
async def on_list_prompts(
|
|
307
|
+
self,
|
|
308
|
+
context: MiddlewareContext[mcp.types.ListPromptsRequest],
|
|
309
|
+
call_next: CallNext[mcp.types.ListPromptsRequest, Sequence[Prompt]],
|
|
310
|
+
) -> Sequence[Prompt]:
|
|
311
|
+
"""List prompts from the cache, if caching is enabled, and the result is in the cache. Otherwise,
|
|
312
|
+
otherwise call the next middleware and store the result in the cache if caching is enabled."""
|
|
313
|
+
if self._list_prompts_settings.get("enabled") is False:
|
|
314
|
+
return await call_next(context)
|
|
315
|
+
|
|
316
|
+
if cached_value := await self._list_prompts_cache.get(key=GLOBAL_KEY):
|
|
317
|
+
return cached_value
|
|
318
|
+
|
|
319
|
+
prompts: Sequence[Prompt] = await call_next(context=context)
|
|
320
|
+
|
|
321
|
+
# Turn any subclass of Prompt into a Prompt
|
|
322
|
+
cachable_prompts: list[Prompt] = [
|
|
323
|
+
Prompt(
|
|
324
|
+
name=prompt.name,
|
|
325
|
+
title=prompt.title,
|
|
326
|
+
description=prompt.description,
|
|
327
|
+
tags=prompt.tags,
|
|
328
|
+
meta=prompt.meta,
|
|
329
|
+
enabled=prompt.enabled,
|
|
330
|
+
arguments=prompt.arguments,
|
|
331
|
+
)
|
|
332
|
+
for prompt in prompts
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
await self._list_prompts_cache.put(
|
|
336
|
+
key=GLOBAL_KEY,
|
|
337
|
+
value=cachable_prompts,
|
|
338
|
+
ttl=self._list_prompts_settings.get("ttl", FIVE_MINUTES_IN_SECONDS),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
return cachable_prompts
|
|
342
|
+
|
|
343
|
+
@override
|
|
344
|
+
async def on_call_tool(
|
|
345
|
+
self,
|
|
346
|
+
context: MiddlewareContext[mcp.types.CallToolRequestParams],
|
|
347
|
+
call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult],
|
|
348
|
+
) -> ToolResult:
|
|
349
|
+
"""Call a tool from the cache, if caching is enabled, and the result is in the cache. Otherwise,
|
|
350
|
+
otherwise call the next middleware and store the result in the cache if caching is enabled."""
|
|
351
|
+
tool_name = context.message.name
|
|
352
|
+
|
|
353
|
+
if self._call_tool_settings.get(
|
|
354
|
+
"enabled"
|
|
355
|
+
) is False or not self._matches_tool_cache_settings(tool_name=tool_name):
|
|
356
|
+
return await call_next(context=context)
|
|
357
|
+
|
|
358
|
+
cache_key: str = f"{tool_name}:{_get_arguments_str(context.message.arguments)}"
|
|
359
|
+
|
|
360
|
+
if cached_value := await self._call_tool_cache.get(key=cache_key):
|
|
361
|
+
return cached_value.unwrap()
|
|
362
|
+
|
|
363
|
+
tool_result: ToolResult = await call_next(context=context)
|
|
364
|
+
cachable_tool_result: CachableToolResult = CachableToolResult.wrap(
|
|
365
|
+
value=tool_result
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
await self._call_tool_cache.put(
|
|
369
|
+
key=cache_key,
|
|
370
|
+
value=cachable_tool_result,
|
|
371
|
+
ttl=self._call_tool_settings.get("ttl", ONE_HOUR_IN_SECONDS),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
return cachable_tool_result.unwrap()
|
|
375
|
+
|
|
376
|
+
@override
|
|
377
|
+
async def on_read_resource(
|
|
378
|
+
self,
|
|
379
|
+
context: MiddlewareContext[mcp.types.ReadResourceRequestParams],
|
|
380
|
+
call_next: CallNext[
|
|
381
|
+
mcp.types.ReadResourceRequestParams, Sequence[ReadResourceContents]
|
|
382
|
+
],
|
|
383
|
+
) -> Sequence[ReadResourceContents]:
|
|
384
|
+
"""Read a resource from the cache, if caching is enabled, and the result is in the cache. Otherwise,
|
|
385
|
+
otherwise call the next middleware and store the result in the cache if caching is enabled."""
|
|
386
|
+
if self._read_resource_settings.get("enabled") is False:
|
|
387
|
+
return await call_next(context=context)
|
|
388
|
+
|
|
389
|
+
cache_key: str = str(context.message.uri)
|
|
390
|
+
cached_value: list[CachableReadResourceContents] | None
|
|
391
|
+
|
|
392
|
+
if cached_value := await self._read_resource_cache.get(key=cache_key):
|
|
393
|
+
return CachableReadResourceContents.unwrap(values=cached_value)
|
|
394
|
+
|
|
395
|
+
value: Sequence[ReadResourceContents] = await call_next(context=context)
|
|
396
|
+
cached_value = CachableReadResourceContents.wrap(values=value)
|
|
397
|
+
|
|
398
|
+
await self._read_resource_cache.put(
|
|
399
|
+
key=cache_key,
|
|
400
|
+
value=cached_value,
|
|
401
|
+
ttl=self._read_resource_settings.get("ttl", ONE_HOUR_IN_SECONDS),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
return CachableReadResourceContents.unwrap(values=cached_value)
|
|
405
|
+
|
|
406
|
+
@override
|
|
407
|
+
async def on_get_prompt(
|
|
408
|
+
self,
|
|
409
|
+
context: MiddlewareContext[mcp.types.GetPromptRequestParams],
|
|
410
|
+
call_next: CallNext[
|
|
411
|
+
mcp.types.GetPromptRequestParams, mcp.types.GetPromptResult
|
|
412
|
+
],
|
|
413
|
+
) -> mcp.types.GetPromptResult:
|
|
414
|
+
"""Get a prompt from the cache, if caching is enabled, and the result is in the cache. Otherwise,
|
|
415
|
+
otherwise call the next middleware and store the result in the cache if caching is enabled."""
|
|
416
|
+
if self._get_prompt_settings.get("enabled") is False:
|
|
417
|
+
return await call_next(context=context)
|
|
418
|
+
|
|
419
|
+
cache_key: str = f"{context.message.name}:{_get_arguments_str(arguments=context.message.arguments)}"
|
|
420
|
+
|
|
421
|
+
if cached_value := await self._get_prompt_cache.get(key=cache_key):
|
|
422
|
+
return cached_value
|
|
423
|
+
|
|
424
|
+
value: mcp.types.GetPromptResult = await call_next(context=context)
|
|
425
|
+
|
|
426
|
+
await self._get_prompt_cache.put(
|
|
427
|
+
key=cache_key,
|
|
428
|
+
value=value,
|
|
429
|
+
ttl=self._get_prompt_settings.get("ttl", ONE_HOUR_IN_SECONDS),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
return value
|
|
433
|
+
|
|
434
|
+
def _matches_tool_cache_settings(self, tool_name: str) -> bool:
|
|
435
|
+
"""Check if the tool matches the cache settings for tool calls."""
|
|
436
|
+
|
|
437
|
+
if included_tools := self._call_tool_settings.get("included_tools"):
|
|
438
|
+
if tool_name not in included_tools:
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
if excluded_tools := self._call_tool_settings.get("excluded_tools"):
|
|
442
|
+
if tool_name in excluded_tools:
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
return True
|
|
446
|
+
|
|
447
|
+
def statistics(self) -> ResponseCachingStatistics:
|
|
448
|
+
"""Get the statistics for the cache."""
|
|
449
|
+
return ResponseCachingStatistics(
|
|
450
|
+
list_tools=self._stats.statistics.collections.get("tools/list"),
|
|
451
|
+
list_resources=self._stats.statistics.collections.get("resources/list"),
|
|
452
|
+
list_prompts=self._stats.statistics.collections.get("prompts/list"),
|
|
453
|
+
read_resource=self._stats.statistics.collections.get("resources/read"),
|
|
454
|
+
get_prompt=self._stats.statistics.collections.get("prompts/get"),
|
|
455
|
+
call_tool=self._stats.statistics.collections.get("tools/call"),
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _get_arguments_str(arguments: dict[str, Any] | None) -> str:
|
|
460
|
+
"""Get a string representation of the arguments."""
|
|
461
|
+
|
|
462
|
+
if arguments is None:
|
|
463
|
+
return "null"
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
return pydantic_core.to_json(value=arguments, fallback=str).decode()
|
|
467
|
+
|
|
468
|
+
except TypeError:
|
|
469
|
+
return repr(arguments)
|
|
@@ -6,9 +6,12 @@ import traceback
|
|
|
6
6
|
from collections.abc import Callable
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
+
import anyio
|
|
9
10
|
from mcp import McpError
|
|
10
11
|
from mcp.types import ErrorData
|
|
11
12
|
|
|
13
|
+
from fastmcp.exceptions import NotFoundError
|
|
14
|
+
|
|
12
15
|
from .middleware import CallNext, Middleware, MiddlewareContext
|
|
13
16
|
|
|
14
17
|
|
|
@@ -90,7 +93,7 @@ class ErrorHandlingMiddleware(Middleware):
|
|
|
90
93
|
return McpError(
|
|
91
94
|
ErrorData(code=-32602, message=f"Invalid params: {str(error)}")
|
|
92
95
|
)
|
|
93
|
-
elif error_type in (FileNotFoundError, KeyError):
|
|
96
|
+
elif error_type in (FileNotFoundError, KeyError, NotFoundError):
|
|
94
97
|
return McpError(
|
|
95
98
|
ErrorData(code=-32001, message=f"Resource not found: {str(error)}")
|
|
96
99
|
)
|
|
@@ -98,6 +101,7 @@ class ErrorHandlingMiddleware(Middleware):
|
|
|
98
101
|
return McpError(
|
|
99
102
|
ErrorData(code=-32000, message=f"Permission denied: {str(error)}")
|
|
100
103
|
)
|
|
104
|
+
# asyncio.TimeoutError is a subclass of TimeoutError in Python 3.10, alias in 3.11+
|
|
101
105
|
elif error_type in (TimeoutError, asyncio.TimeoutError):
|
|
102
106
|
return McpError(
|
|
103
107
|
ErrorData(code=-32000, message=f"Request timeout: {str(error)}")
|
|
@@ -199,7 +203,7 @@ class RetryMiddleware(Middleware):
|
|
|
199
203
|
f"{type(error).__name__}: {str(error)}. Retrying in {delay:.1f}s..."
|
|
200
204
|
)
|
|
201
205
|
|
|
202
|
-
await
|
|
206
|
+
await anyio.sleep(delay)
|
|
203
207
|
|
|
204
208
|
# Re-raise the last error if all retries failed
|
|
205
209
|
if last_error:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
|
+
import time
|
|
5
6
|
from collections.abc import Callable
|
|
6
7
|
from logging import Logger
|
|
7
8
|
from typing import Any
|
|
@@ -52,14 +53,14 @@ class BaseLoggingMiddleware(Middleware):
|
|
|
52
53
|
else:
|
|
53
54
|
return " ".join([f"{k}={v}" for k, v in message.items()])
|
|
54
55
|
|
|
55
|
-
def _get_timestamp_from_context(self, context: MiddlewareContext[Any]) -> str:
|
|
56
|
-
"""Get a timestamp from the context."""
|
|
57
|
-
return context.timestamp.isoformat()
|
|
58
|
-
|
|
59
56
|
def _create_before_message(
|
|
60
|
-
self, context: MiddlewareContext[Any]
|
|
57
|
+
self, context: MiddlewareContext[Any]
|
|
61
58
|
) -> dict[str, str | int]:
|
|
62
|
-
message =
|
|
59
|
+
message = {
|
|
60
|
+
"event": context.type + "_start",
|
|
61
|
+
"method": context.method or "unknown",
|
|
62
|
+
"source": context.source,
|
|
63
|
+
}
|
|
63
64
|
|
|
64
65
|
if (
|
|
65
66
|
self.include_payloads
|
|
@@ -85,57 +86,61 @@ class BaseLoggingMiddleware(Middleware):
|
|
|
85
86
|
|
|
86
87
|
return message
|
|
87
88
|
|
|
88
|
-
def
|
|
89
|
-
self, context: MiddlewareContext[Any], event: str
|
|
90
|
-
) -> dict[str, str | int]:
|
|
91
|
-
return self._create_base_message(context, event)
|
|
92
|
-
|
|
93
|
-
def _create_base_message(
|
|
89
|
+
def _create_error_message(
|
|
94
90
|
self,
|
|
95
91
|
context: MiddlewareContext[Any],
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
92
|
+
start_time: float,
|
|
93
|
+
error: Exception,
|
|
94
|
+
) -> dict[str, str | int | float]:
|
|
95
|
+
duration_ms: float = _get_duration_ms(start_time)
|
|
96
|
+
message = {
|
|
97
|
+
"event": context.type + "_error",
|
|
98
|
+
"method": context.method or "unknown",
|
|
99
|
+
"source": context.source,
|
|
100
|
+
"duration_ms": duration_ms,
|
|
101
|
+
"error": str(object=error),
|
|
102
|
+
}
|
|
103
|
+
return message
|
|
99
104
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
def _create_after_message(
|
|
106
|
+
self,
|
|
107
|
+
context: MiddlewareContext[Any],
|
|
108
|
+
start_time: float,
|
|
109
|
+
) -> dict[str, str | int | float]:
|
|
110
|
+
duration_ms: float = _get_duration_ms(start_time)
|
|
111
|
+
message = {
|
|
112
|
+
"event": context.type + "_success",
|
|
103
113
|
"method": context.method or "unknown",
|
|
104
|
-
"type": context.type,
|
|
105
114
|
"source": context.source,
|
|
115
|
+
"duration_ms": duration_ms,
|
|
106
116
|
}
|
|
117
|
+
return message
|
|
107
118
|
|
|
108
|
-
|
|
119
|
+
def _log_message(
|
|
120
|
+
self, message: dict[str, str | int | float], log_level: int | None = None
|
|
121
|
+
):
|
|
122
|
+
self.logger.log(log_level or self.log_level, self._format_message(message))
|
|
109
123
|
|
|
110
124
|
async def on_message(
|
|
111
125
|
self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]
|
|
112
126
|
) -> Any:
|
|
113
|
-
"""Log
|
|
127
|
+
"""Log messages for configured methods."""
|
|
114
128
|
|
|
115
129
|
if self.methods and context.method not in self.methods:
|
|
116
130
|
return await call_next(context)
|
|
117
131
|
|
|
118
|
-
|
|
119
|
-
context, "request_start"
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
formatted_message = self._format_message(request_start_log_message)
|
|
123
|
-
self.logger.log(self.log_level, f"Processing message: {formatted_message}")
|
|
132
|
+
self._log_message(self._create_before_message(context))
|
|
124
133
|
|
|
134
|
+
start_time = time.perf_counter()
|
|
125
135
|
try:
|
|
126
136
|
result = await call_next(context)
|
|
127
137
|
|
|
128
|
-
|
|
129
|
-
context, "request_success"
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
formatted_message = self._format_message(request_success_log_message)
|
|
133
|
-
self.logger.log(self.log_level, f"Completed message: {formatted_message}")
|
|
138
|
+
self._log_message(self._create_after_message(context, start_time))
|
|
134
139
|
|
|
135
140
|
return result
|
|
136
141
|
except Exception as e:
|
|
137
|
-
self.
|
|
138
|
-
|
|
142
|
+
self._log_message(
|
|
143
|
+
self._create_error_message(context, start_time, e), logging.ERROR
|
|
139
144
|
)
|
|
140
145
|
raise
|
|
141
146
|
|
|
@@ -184,7 +189,7 @@ class LoggingMiddleware(BaseLoggingMiddleware):
|
|
|
184
189
|
payload_serializer: Callable that converts objects to a JSON string for the
|
|
185
190
|
payload. If not provided, uses FastMCP's default tool serializer.
|
|
186
191
|
"""
|
|
187
|
-
self.logger: Logger = logger or logging.getLogger("fastmcp.
|
|
192
|
+
self.logger: Logger = logger or logging.getLogger("fastmcp.middleware.logging")
|
|
188
193
|
self.log_level = log_level
|
|
189
194
|
self.include_payloads: bool = include_payloads
|
|
190
195
|
self.include_payload_length: bool = include_payload_length
|
|
@@ -234,7 +239,9 @@ class StructuredLoggingMiddleware(BaseLoggingMiddleware):
|
|
|
234
239
|
payload_serializer: Callable that converts objects to a JSON string for the
|
|
235
240
|
payload. If not provided, uses FastMCP's default tool serializer.
|
|
236
241
|
"""
|
|
237
|
-
self.logger: Logger = logger or logging.getLogger(
|
|
242
|
+
self.logger: Logger = logger or logging.getLogger(
|
|
243
|
+
"fastmcp.middleware.structured_logging"
|
|
244
|
+
)
|
|
238
245
|
self.log_level: int = log_level
|
|
239
246
|
self.include_payloads: bool = include_payloads
|
|
240
247
|
self.include_payload_length: bool = include_payload_length
|
|
@@ -243,3 +250,7 @@ class StructuredLoggingMiddleware(BaseLoggingMiddleware):
|
|
|
243
250
|
self.payload_serializer: Callable[[Any], str] | None = payload_serializer
|
|
244
251
|
self.max_payload_length: int | None = None
|
|
245
252
|
self.structured_logging: bool = True
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _get_duration_ms(start_time: float, /) -> float:
|
|
256
|
+
return round(number=(time.perf_counter() - start_time) * 1000, ndigits=2)
|