fastmcp 2.12.4__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.
Files changed (72) hide show
  1. fastmcp/cli/cli.py +7 -6
  2. fastmcp/cli/install/claude_code.py +6 -6
  3. fastmcp/cli/install/claude_desktop.py +3 -3
  4. fastmcp/cli/install/cursor.py +7 -7
  5. fastmcp/cli/install/gemini_cli.py +3 -3
  6. fastmcp/cli/install/mcp_json.py +3 -3
  7. fastmcp/cli/run.py +13 -8
  8. fastmcp/client/auth/oauth.py +100 -208
  9. fastmcp/client/client.py +11 -11
  10. fastmcp/client/logging.py +18 -14
  11. fastmcp/client/oauth_callback.py +85 -171
  12. fastmcp/client/transports.py +77 -22
  13. fastmcp/contrib/component_manager/component_service.py +6 -6
  14. fastmcp/contrib/mcp_mixin/README.md +32 -1
  15. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
  16. fastmcp/experimental/utilities/openapi/json_schema_converter.py +4 -0
  17. fastmcp/experimental/utilities/openapi/parser.py +23 -3
  18. fastmcp/prompts/prompt.py +13 -6
  19. fastmcp/prompts/prompt_manager.py +16 -101
  20. fastmcp/resources/resource.py +13 -6
  21. fastmcp/resources/resource_manager.py +5 -164
  22. fastmcp/resources/template.py +107 -17
  23. fastmcp/resources/types.py +30 -24
  24. fastmcp/server/auth/auth.py +40 -32
  25. fastmcp/server/auth/handlers/authorize.py +324 -0
  26. fastmcp/server/auth/jwt_issuer.py +236 -0
  27. fastmcp/server/auth/middleware.py +96 -0
  28. fastmcp/server/auth/oauth_proxy.py +1256 -242
  29. fastmcp/server/auth/oidc_proxy.py +23 -6
  30. fastmcp/server/auth/providers/auth0.py +40 -21
  31. fastmcp/server/auth/providers/aws.py +29 -3
  32. fastmcp/server/auth/providers/azure.py +178 -127
  33. fastmcp/server/auth/providers/descope.py +4 -6
  34. fastmcp/server/auth/providers/github.py +29 -8
  35. fastmcp/server/auth/providers/google.py +30 -9
  36. fastmcp/server/auth/providers/introspection.py +281 -0
  37. fastmcp/server/auth/providers/jwt.py +8 -2
  38. fastmcp/server/auth/providers/scalekit.py +179 -0
  39. fastmcp/server/auth/providers/supabase.py +172 -0
  40. fastmcp/server/auth/providers/workos.py +32 -14
  41. fastmcp/server/context.py +122 -36
  42. fastmcp/server/http.py +58 -18
  43. fastmcp/server/low_level.py +121 -2
  44. fastmcp/server/middleware/caching.py +469 -0
  45. fastmcp/server/middleware/error_handling.py +6 -2
  46. fastmcp/server/middleware/logging.py +48 -37
  47. fastmcp/server/middleware/middleware.py +28 -15
  48. fastmcp/server/middleware/rate_limiting.py +3 -3
  49. fastmcp/server/middleware/tool_injection.py +116 -0
  50. fastmcp/server/proxy.py +6 -6
  51. fastmcp/server/server.py +683 -207
  52. fastmcp/settings.py +24 -10
  53. fastmcp/tools/tool.py +7 -3
  54. fastmcp/tools/tool_manager.py +30 -112
  55. fastmcp/tools/tool_transform.py +3 -3
  56. fastmcp/utilities/cli.py +62 -22
  57. fastmcp/utilities/components.py +5 -0
  58. fastmcp/utilities/inspect.py +77 -21
  59. fastmcp/utilities/logging.py +118 -8
  60. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  61. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
  62. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  63. fastmcp/utilities/tests.py +87 -4
  64. fastmcp/utilities/types.py +1 -1
  65. fastmcp/utilities/ui.py +617 -0
  66. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0.dist-info}/METADATA +10 -6
  67. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0.dist-info}/RECORD +70 -63
  68. fastmcp/cli/claude.py +0 -135
  69. fastmcp/utilities/storage.py +0 -204
  70. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0.dist-info}/WHEEL +0 -0
  71. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0.dist-info}/entry_points.txt +0 -0
  72. {fastmcp-2.12.4.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 asyncio.sleep(delay)
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], event: str
57
+ self, context: MiddlewareContext[Any]
61
58
  ) -> dict[str, str | int]:
62
- message = self._create_base_message(context, event)
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 _create_after_message(
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
- event: str,
97
- ) -> dict[str, str | int]:
98
- """Format a message for logging."""
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
- parts: dict[str, str | int] = {
101
- "event": event,
102
- "timestamp": self._get_timestamp_from_context(context),
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
- return parts
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 all messages."""
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
- request_start_log_message = self._create_before_message(
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
- request_success_log_message = self._create_after_message(
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.logger.log(
138
- logging.ERROR, f"Failed message: {context.method or 'unknown'} - {e}"
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.requests")
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("fastmcp.structured")
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)