fastmcp 2.8.1__py3-none-any.whl → 2.9.1__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 +99 -1
- fastmcp/cli/run.py +1 -3
- fastmcp/client/auth/oauth.py +1 -2
- fastmcp/client/client.py +23 -7
- fastmcp/client/logging.py +1 -2
- fastmcp/client/messages.py +126 -0
- fastmcp/client/transports.py +17 -2
- fastmcp/contrib/mcp_mixin/README.md +79 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -0
- fastmcp/prompts/prompt.py +109 -13
- fastmcp/prompts/prompt_manager.py +119 -43
- fastmcp/resources/resource.py +27 -1
- fastmcp/resources/resource_manager.py +249 -76
- fastmcp/resources/template.py +44 -2
- fastmcp/server/auth/providers/bearer.py +62 -13
- fastmcp/server/context.py +113 -10
- fastmcp/server/http.py +8 -0
- fastmcp/server/low_level.py +35 -0
- fastmcp/server/middleware/__init__.py +6 -0
- fastmcp/server/middleware/error_handling.py +206 -0
- fastmcp/server/middleware/logging.py +165 -0
- fastmcp/server/middleware/middleware.py +236 -0
- fastmcp/server/middleware/rate_limiting.py +231 -0
- fastmcp/server/middleware/timing.py +156 -0
- fastmcp/server/proxy.py +250 -140
- fastmcp/server/server.py +446 -280
- fastmcp/settings.py +2 -2
- fastmcp/tools/tool.py +22 -2
- fastmcp/tools/tool_manager.py +114 -45
- fastmcp/tools/tool_transform.py +42 -16
- fastmcp/utilities/components.py +22 -2
- fastmcp/utilities/inspect.py +326 -0
- fastmcp/utilities/json_schema.py +67 -23
- fastmcp/utilities/mcp_config.py +13 -7
- fastmcp/utilities/openapi.py +75 -5
- fastmcp/utilities/tests.py +1 -1
- fastmcp/utilities/types.py +90 -1
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/METADATA +2 -2
- fastmcp-2.9.1.dist-info/RECORD +78 -0
- fastmcp-2.8.1.dist-info/RECORD +0 -69
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/licenses/LICENSE +0 -0
fastmcp/server/context.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations as _annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import warnings
|
|
4
5
|
from collections.abc import Generator
|
|
5
6
|
from contextlib import contextmanager
|
|
6
7
|
from contextvars import ContextVar, Token
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
|
|
9
|
-
from mcp import LoggingLevel
|
|
10
|
+
from mcp import LoggingLevel, ServerSession
|
|
10
11
|
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
12
|
+
from mcp.server.lowlevel.server import request_ctx
|
|
11
13
|
from mcp.shared.context import RequestContext
|
|
12
14
|
from mcp.types import (
|
|
13
15
|
CreateMessageResult,
|
|
@@ -29,6 +31,7 @@ from fastmcp.utilities.types import MCPContent
|
|
|
29
31
|
logger = get_logger(__name__)
|
|
30
32
|
|
|
31
33
|
_current_context: ContextVar[Context | None] = ContextVar("context", default=None)
|
|
34
|
+
_flush_lock = asyncio.Lock()
|
|
32
35
|
|
|
33
36
|
|
|
34
37
|
@contextmanager
|
|
@@ -79,24 +82,34 @@ class Context:
|
|
|
79
82
|
def __init__(self, fastmcp: FastMCP):
|
|
80
83
|
self.fastmcp = fastmcp
|
|
81
84
|
self._tokens: list[Token] = []
|
|
85
|
+
self._notification_queue: set[str] = set() # Dedupe notifications
|
|
82
86
|
|
|
83
|
-
def
|
|
87
|
+
async def __aenter__(self) -> Context:
|
|
84
88
|
"""Enter the context manager and set this context as the current context."""
|
|
85
89
|
# Always set this context and save the token
|
|
86
90
|
token = _current_context.set(self)
|
|
87
91
|
self._tokens.append(token)
|
|
88
92
|
return self
|
|
89
93
|
|
|
90
|
-
def
|
|
94
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
91
95
|
"""Exit the context manager and reset the most recent token."""
|
|
96
|
+
# Flush any remaining notifications before exiting
|
|
97
|
+
await self._flush_notifications()
|
|
98
|
+
|
|
92
99
|
if self._tokens:
|
|
93
100
|
token = self._tokens.pop()
|
|
94
101
|
_current_context.reset(token)
|
|
95
102
|
|
|
96
103
|
@property
|
|
97
104
|
def request_context(self) -> RequestContext:
|
|
98
|
-
"""Access to the underlying request context.
|
|
99
|
-
|
|
105
|
+
"""Access to the underlying request context.
|
|
106
|
+
|
|
107
|
+
If called outside of a request context, this will raise a ValueError.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
return request_ctx.get()
|
|
111
|
+
except LookupError:
|
|
112
|
+
raise ValueError("Context is not available outside of a request")
|
|
100
113
|
|
|
101
114
|
async def report_progress(
|
|
102
115
|
self, progress: float, total: float | None = None, message: str | None = None
|
|
@@ -117,11 +130,12 @@ class Context:
|
|
|
117
130
|
if progress_token is None:
|
|
118
131
|
return
|
|
119
132
|
|
|
120
|
-
await self.
|
|
133
|
+
await self.session.send_progress_notification(
|
|
121
134
|
progress_token=progress_token,
|
|
122
135
|
progress=progress,
|
|
123
136
|
total=total,
|
|
124
137
|
message=message,
|
|
138
|
+
related_request_id=self.request_id,
|
|
125
139
|
)
|
|
126
140
|
|
|
127
141
|
async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]:
|
|
@@ -152,7 +166,7 @@ class Context:
|
|
|
152
166
|
"""
|
|
153
167
|
if level is None:
|
|
154
168
|
level = "info"
|
|
155
|
-
await self.
|
|
169
|
+
await self.session.send_log_message(
|
|
156
170
|
level=level, data=message, logger=logger_name
|
|
157
171
|
)
|
|
158
172
|
|
|
@@ -171,7 +185,38 @@ class Context:
|
|
|
171
185
|
return str(self.request_context.request_id)
|
|
172
186
|
|
|
173
187
|
@property
|
|
174
|
-
def
|
|
188
|
+
def session_id(self) -> str | None:
|
|
189
|
+
"""Get the MCP session ID for HTTP transports.
|
|
190
|
+
|
|
191
|
+
Returns the session ID that can be used as a key for session-based
|
|
192
|
+
data storage (e.g., Redis) to share data between tool calls within
|
|
193
|
+
the same client session.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
The session ID for HTTP transports (SSE, StreamableHTTP), or None
|
|
197
|
+
for stdio and in-memory transports which don't use session IDs.
|
|
198
|
+
|
|
199
|
+
Example:
|
|
200
|
+
```python
|
|
201
|
+
@server.tool
|
|
202
|
+
def store_data(data: dict, ctx: Context) -> str:
|
|
203
|
+
if session_id := ctx.session_id:
|
|
204
|
+
redis_client.set(f"session:{session_id}:data", json.dumps(data))
|
|
205
|
+
return f"Data stored for session {session_id}"
|
|
206
|
+
return "No session ID available (stdio/memory transport)"
|
|
207
|
+
```
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
from fastmcp.server.dependencies import get_http_headers
|
|
211
|
+
|
|
212
|
+
headers = get_http_headers(include_all=True)
|
|
213
|
+
return headers.get("mcp-session-id")
|
|
214
|
+
except RuntimeError:
|
|
215
|
+
# No HTTP context available (stdio/in-memory transport)
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def session(self) -> ServerSession:
|
|
175
220
|
"""Access to the underlying session for advanced usage."""
|
|
176
221
|
return self.request_context.session
|
|
177
222
|
|
|
@@ -194,9 +239,21 @@ class Context:
|
|
|
194
239
|
|
|
195
240
|
async def list_roots(self) -> list[Root]:
|
|
196
241
|
"""List the roots available to the server, as indicated by the client."""
|
|
197
|
-
result = await self.
|
|
242
|
+
result = await self.session.list_roots()
|
|
198
243
|
return result.roots
|
|
199
244
|
|
|
245
|
+
async def send_tool_list_changed(self) -> None:
|
|
246
|
+
"""Send a tool list changed notification to the client."""
|
|
247
|
+
await self.session.send_tool_list_changed()
|
|
248
|
+
|
|
249
|
+
async def send_resource_list_changed(self) -> None:
|
|
250
|
+
"""Send a resource list changed notification to the client."""
|
|
251
|
+
await self.session.send_resource_list_changed()
|
|
252
|
+
|
|
253
|
+
async def send_prompt_list_changed(self) -> None:
|
|
254
|
+
"""Send a prompt list changed notification to the client."""
|
|
255
|
+
await self.session.send_prompt_list_changed()
|
|
256
|
+
|
|
200
257
|
async def sample(
|
|
201
258
|
self,
|
|
202
259
|
messages: str | list[str | SamplingMessage],
|
|
@@ -230,7 +287,7 @@ class Context:
|
|
|
230
287
|
for m in messages
|
|
231
288
|
]
|
|
232
289
|
|
|
233
|
-
result: CreateMessageResult = await self.
|
|
290
|
+
result: CreateMessageResult = await self.session.create_message(
|
|
234
291
|
messages=sampling_messages,
|
|
235
292
|
system_prompt=system_prompt,
|
|
236
293
|
temperature=temperature,
|
|
@@ -255,6 +312,52 @@ class Context:
|
|
|
255
312
|
|
|
256
313
|
return fastmcp.server.dependencies.get_http_request()
|
|
257
314
|
|
|
315
|
+
def _queue_tool_list_changed(self) -> None:
|
|
316
|
+
"""Queue a tool list changed notification."""
|
|
317
|
+
self._notification_queue.add("notifications/tools/list_changed")
|
|
318
|
+
self._try_flush_notifications()
|
|
319
|
+
|
|
320
|
+
def _queue_resource_list_changed(self) -> None:
|
|
321
|
+
"""Queue a resource list changed notification."""
|
|
322
|
+
self._notification_queue.add("notifications/resources/list_changed")
|
|
323
|
+
self._try_flush_notifications()
|
|
324
|
+
|
|
325
|
+
def _queue_prompt_list_changed(self) -> None:
|
|
326
|
+
"""Queue a prompt list changed notification."""
|
|
327
|
+
self._notification_queue.add("notifications/prompts/list_changed")
|
|
328
|
+
self._try_flush_notifications()
|
|
329
|
+
|
|
330
|
+
def _try_flush_notifications(self) -> None:
|
|
331
|
+
"""Synchronous method that attempts to flush notifications if we're in an async context."""
|
|
332
|
+
try:
|
|
333
|
+
# Check if we're in an async context
|
|
334
|
+
loop = asyncio.get_running_loop()
|
|
335
|
+
if loop and not loop.is_running():
|
|
336
|
+
return
|
|
337
|
+
# Schedule flush as a task (fire-and-forget)
|
|
338
|
+
asyncio.create_task(self._flush_notifications())
|
|
339
|
+
except RuntimeError:
|
|
340
|
+
# No event loop - will flush later
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
async def _flush_notifications(self) -> None:
|
|
344
|
+
"""Send all queued notifications."""
|
|
345
|
+
async with _flush_lock:
|
|
346
|
+
if not self._notification_queue:
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
if "notifications/tools/list_changed" in self._notification_queue:
|
|
351
|
+
await self.session.send_tool_list_changed()
|
|
352
|
+
if "notifications/resources/list_changed" in self._notification_queue:
|
|
353
|
+
await self.session.send_resource_list_changed()
|
|
354
|
+
if "notifications/prompts/list_changed" in self._notification_queue:
|
|
355
|
+
await self.session.send_prompt_list_changed()
|
|
356
|
+
self._notification_queue.clear()
|
|
357
|
+
except Exception:
|
|
358
|
+
# Don't let notification failures break the request
|
|
359
|
+
pass
|
|
360
|
+
|
|
258
361
|
def _parse_model_preferences(
|
|
259
362
|
self, model_preferences: ModelPreferences | str | list[str] | None
|
|
260
363
|
) -> ModelPreferences | None:
|
fastmcp/server/http.py
CHANGED
|
@@ -158,6 +158,10 @@ def create_sse_app(
|
|
|
158
158
|
A Starlette application with RequestContextMiddleware
|
|
159
159
|
"""
|
|
160
160
|
|
|
161
|
+
# Ensure the message_path ends with a trailing slash to avoid automatic redirects
|
|
162
|
+
if not message_path.endswith("/"):
|
|
163
|
+
message_path = message_path + "/"
|
|
164
|
+
|
|
161
165
|
server_routes: list[BaseRoute] = []
|
|
162
166
|
server_middleware: list[Middleware] = []
|
|
163
167
|
|
|
@@ -305,6 +309,10 @@ def create_streamable_http_app(
|
|
|
305
309
|
# Re-raise other RuntimeErrors if they don't match the specific message
|
|
306
310
|
raise
|
|
307
311
|
|
|
312
|
+
# Ensure the streamable_http_path ends with a trailing slash to avoid automatic redirects
|
|
313
|
+
if not streamable_http_path.endswith("/"):
|
|
314
|
+
streamable_http_path = streamable_http_path + "/"
|
|
315
|
+
|
|
308
316
|
# Add StreamableHTTP routes with or without auth
|
|
309
317
|
if auth:
|
|
310
318
|
auth_middleware, auth_routes, required_scopes = (
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from mcp.server.lowlevel.server import (
|
|
4
|
+
LifespanResultT,
|
|
5
|
+
NotificationOptions,
|
|
6
|
+
RequestT,
|
|
7
|
+
Server,
|
|
8
|
+
)
|
|
9
|
+
from mcp.server.models import InitializationOptions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LowLevelServer(Server[LifespanResultT, RequestT]):
|
|
13
|
+
def __init__(self, *args, **kwargs):
|
|
14
|
+
super().__init__(*args, **kwargs)
|
|
15
|
+
# FastMCP servers support notifications for all components
|
|
16
|
+
self.notification_options = NotificationOptions(
|
|
17
|
+
prompts_changed=True,
|
|
18
|
+
resources_changed=True,
|
|
19
|
+
tools_changed=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def create_initialization_options(
|
|
23
|
+
self,
|
|
24
|
+
notification_options: NotificationOptions | None = None,
|
|
25
|
+
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
|
|
26
|
+
**kwargs: Any,
|
|
27
|
+
) -> InitializationOptions:
|
|
28
|
+
# ensure we use the FastMCP notification options
|
|
29
|
+
if notification_options is None:
|
|
30
|
+
notification_options = self.notification_options
|
|
31
|
+
return super().create_initialization_options(
|
|
32
|
+
notification_options=notification_options,
|
|
33
|
+
experimental_capabilities=experimental_capabilities,
|
|
34
|
+
**kwargs,
|
|
35
|
+
)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Error handling middleware for consistent error responses and tracking."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import traceback
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from mcp import McpError
|
|
10
|
+
from mcp.types import ErrorData
|
|
11
|
+
|
|
12
|
+
from .middleware import CallNext, Middleware, MiddlewareContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ErrorHandlingMiddleware(Middleware):
|
|
16
|
+
"""Middleware that provides consistent error handling and logging.
|
|
17
|
+
|
|
18
|
+
Catches exceptions, logs them appropriately, and converts them to
|
|
19
|
+
proper MCP error responses. Also tracks error patterns for monitoring.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
```python
|
|
23
|
+
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
|
|
24
|
+
import logging
|
|
25
|
+
|
|
26
|
+
# Configure logging to see error details
|
|
27
|
+
logging.basicConfig(level=logging.ERROR)
|
|
28
|
+
|
|
29
|
+
mcp = FastMCP("MyServer")
|
|
30
|
+
mcp.add_middleware(ErrorHandlingMiddleware())
|
|
31
|
+
```
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
logger: logging.Logger | None = None,
|
|
37
|
+
include_traceback: bool = False,
|
|
38
|
+
error_callback: Callable[[Exception, MiddlewareContext], None] | None = None,
|
|
39
|
+
transform_errors: bool = True,
|
|
40
|
+
):
|
|
41
|
+
"""Initialize error handling middleware.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
logger: Logger instance for error logging. If None, uses 'fastmcp.errors'
|
|
45
|
+
include_traceback: Whether to include full traceback in error logs
|
|
46
|
+
error_callback: Optional callback function called for each error
|
|
47
|
+
transform_errors: Whether to transform non-MCP errors to McpError
|
|
48
|
+
"""
|
|
49
|
+
self.logger = logger or logging.getLogger("fastmcp.errors")
|
|
50
|
+
self.include_traceback = include_traceback
|
|
51
|
+
self.error_callback = error_callback
|
|
52
|
+
self.transform_errors = transform_errors
|
|
53
|
+
self.error_counts = {}
|
|
54
|
+
|
|
55
|
+
def _log_error(self, error: Exception, context: MiddlewareContext) -> None:
|
|
56
|
+
"""Log error with appropriate detail level."""
|
|
57
|
+
error_type = type(error).__name__
|
|
58
|
+
method = context.method or "unknown"
|
|
59
|
+
|
|
60
|
+
# Track error counts
|
|
61
|
+
error_key = f"{error_type}:{method}"
|
|
62
|
+
self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1
|
|
63
|
+
|
|
64
|
+
base_message = f"Error in {method}: {error_type}: {str(error)}"
|
|
65
|
+
|
|
66
|
+
if self.include_traceback:
|
|
67
|
+
self.logger.error(f"{base_message}\n{traceback.format_exc()}")
|
|
68
|
+
else:
|
|
69
|
+
self.logger.error(base_message)
|
|
70
|
+
|
|
71
|
+
# Call custom error callback if provided
|
|
72
|
+
if self.error_callback:
|
|
73
|
+
try:
|
|
74
|
+
self.error_callback(error, context)
|
|
75
|
+
except Exception as callback_error:
|
|
76
|
+
self.logger.error(f"Error in error callback: {callback_error}")
|
|
77
|
+
|
|
78
|
+
def _transform_error(self, error: Exception) -> Exception:
|
|
79
|
+
"""Transform non-MCP errors to proper MCP errors."""
|
|
80
|
+
if isinstance(error, McpError):
|
|
81
|
+
return error
|
|
82
|
+
|
|
83
|
+
if not self.transform_errors:
|
|
84
|
+
return error
|
|
85
|
+
|
|
86
|
+
# Map common exceptions to appropriate MCP error codes
|
|
87
|
+
error_type = type(error)
|
|
88
|
+
|
|
89
|
+
if error_type in (ValueError, TypeError):
|
|
90
|
+
return McpError(
|
|
91
|
+
ErrorData(code=-32602, message=f"Invalid params: {str(error)}")
|
|
92
|
+
)
|
|
93
|
+
elif error_type in (FileNotFoundError, KeyError):
|
|
94
|
+
return McpError(
|
|
95
|
+
ErrorData(code=-32001, message=f"Resource not found: {str(error)}")
|
|
96
|
+
)
|
|
97
|
+
elif error_type is PermissionError:
|
|
98
|
+
return McpError(
|
|
99
|
+
ErrorData(code=-32000, message=f"Permission denied: {str(error)}")
|
|
100
|
+
)
|
|
101
|
+
elif error_type in (TimeoutError, asyncio.TimeoutError):
|
|
102
|
+
return McpError(
|
|
103
|
+
ErrorData(code=-32000, message=f"Request timeout: {str(error)}")
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
return McpError(
|
|
107
|
+
ErrorData(code=-32603, message=f"Internal error: {str(error)}")
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
|
|
111
|
+
"""Handle errors for all messages."""
|
|
112
|
+
try:
|
|
113
|
+
return await call_next(context)
|
|
114
|
+
except Exception as error:
|
|
115
|
+
self._log_error(error, context)
|
|
116
|
+
|
|
117
|
+
# Transform and re-raise
|
|
118
|
+
transformed_error = self._transform_error(error)
|
|
119
|
+
raise transformed_error
|
|
120
|
+
|
|
121
|
+
def get_error_stats(self) -> dict[str, int]:
|
|
122
|
+
"""Get error statistics for monitoring."""
|
|
123
|
+
return self.error_counts.copy()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class RetryMiddleware(Middleware):
|
|
127
|
+
"""Middleware that implements automatic retry logic for failed requests.
|
|
128
|
+
|
|
129
|
+
Retries requests that fail with transient errors, using exponential
|
|
130
|
+
backoff to avoid overwhelming the server or external dependencies.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
```python
|
|
134
|
+
from fastmcp.server.middleware.error_handling import RetryMiddleware
|
|
135
|
+
|
|
136
|
+
# Retry up to 3 times with exponential backoff
|
|
137
|
+
retry_middleware = RetryMiddleware(
|
|
138
|
+
max_retries=3,
|
|
139
|
+
retry_exceptions=(ConnectionError, TimeoutError)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
mcp = FastMCP("MyServer")
|
|
143
|
+
mcp.add_middleware(retry_middleware)
|
|
144
|
+
```
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
max_retries: int = 3,
|
|
150
|
+
base_delay: float = 1.0,
|
|
151
|
+
max_delay: float = 60.0,
|
|
152
|
+
backoff_multiplier: float = 2.0,
|
|
153
|
+
retry_exceptions: tuple[type[Exception], ...] = (ConnectionError, TimeoutError),
|
|
154
|
+
logger: logging.Logger | None = None,
|
|
155
|
+
):
|
|
156
|
+
"""Initialize retry middleware.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
max_retries: Maximum number of retry attempts
|
|
160
|
+
base_delay: Initial delay between retries in seconds
|
|
161
|
+
max_delay: Maximum delay between retries in seconds
|
|
162
|
+
backoff_multiplier: Multiplier for exponential backoff
|
|
163
|
+
retry_exceptions: Tuple of exception types that should trigger retries
|
|
164
|
+
logger: Logger for retry attempts
|
|
165
|
+
"""
|
|
166
|
+
self.max_retries = max_retries
|
|
167
|
+
self.base_delay = base_delay
|
|
168
|
+
self.max_delay = max_delay
|
|
169
|
+
self.backoff_multiplier = backoff_multiplier
|
|
170
|
+
self.retry_exceptions = retry_exceptions
|
|
171
|
+
self.logger = logger or logging.getLogger("fastmcp.retry")
|
|
172
|
+
|
|
173
|
+
def _should_retry(self, error: Exception) -> bool:
|
|
174
|
+
"""Determine if an error should trigger a retry."""
|
|
175
|
+
return isinstance(error, self.retry_exceptions)
|
|
176
|
+
|
|
177
|
+
def _calculate_delay(self, attempt: int) -> float:
|
|
178
|
+
"""Calculate delay for the given attempt number."""
|
|
179
|
+
delay = self.base_delay * (self.backoff_multiplier**attempt)
|
|
180
|
+
return min(delay, self.max_delay)
|
|
181
|
+
|
|
182
|
+
async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any:
|
|
183
|
+
"""Implement retry logic for requests."""
|
|
184
|
+
last_error = None
|
|
185
|
+
|
|
186
|
+
for attempt in range(self.max_retries + 1):
|
|
187
|
+
try:
|
|
188
|
+
return await call_next(context)
|
|
189
|
+
except Exception as error:
|
|
190
|
+
last_error = error
|
|
191
|
+
|
|
192
|
+
# Don't retry on the last attempt or if it's not a retryable error
|
|
193
|
+
if attempt == self.max_retries or not self._should_retry(error):
|
|
194
|
+
break
|
|
195
|
+
|
|
196
|
+
delay = self._calculate_delay(attempt)
|
|
197
|
+
self.logger.warning(
|
|
198
|
+
f"Request {context.method} failed (attempt {attempt + 1}/{self.max_retries + 1}): "
|
|
199
|
+
f"{type(error).__name__}: {str(error)}. Retrying in {delay:.1f}s..."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
await asyncio.sleep(delay)
|
|
203
|
+
|
|
204
|
+
# Re-raise the last error if all retries failed
|
|
205
|
+
if last_error:
|
|
206
|
+
raise last_error
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Comprehensive logging middleware for FastMCP servers."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .middleware import CallNext, Middleware, MiddlewareContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LoggingMiddleware(Middleware):
|
|
11
|
+
"""Middleware that provides comprehensive request and response logging.
|
|
12
|
+
|
|
13
|
+
Logs all MCP messages with configurable detail levels. Useful for debugging,
|
|
14
|
+
monitoring, and understanding server usage patterns.
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
```python
|
|
18
|
+
from fastmcp.server.middleware.logging import LoggingMiddleware
|
|
19
|
+
import logging
|
|
20
|
+
|
|
21
|
+
# Configure logging
|
|
22
|
+
logging.basicConfig(level=logging.INFO)
|
|
23
|
+
|
|
24
|
+
mcp = FastMCP("MyServer")
|
|
25
|
+
mcp.add_middleware(LoggingMiddleware())
|
|
26
|
+
```
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
logger: logging.Logger | None = None,
|
|
32
|
+
log_level: int = logging.INFO,
|
|
33
|
+
include_payloads: bool = False,
|
|
34
|
+
max_payload_length: int = 1000,
|
|
35
|
+
):
|
|
36
|
+
"""Initialize logging middleware.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
logger: Logger instance to use. If None, creates a logger named 'fastmcp.requests'
|
|
40
|
+
log_level: Log level for messages (default: INFO)
|
|
41
|
+
include_payloads: Whether to include message payloads in logs
|
|
42
|
+
max_payload_length: Maximum length of payload to log (prevents huge logs)
|
|
43
|
+
"""
|
|
44
|
+
self.logger = logger or logging.getLogger("fastmcp.requests")
|
|
45
|
+
self.log_level = log_level
|
|
46
|
+
self.include_payloads = include_payloads
|
|
47
|
+
self.max_payload_length = max_payload_length
|
|
48
|
+
|
|
49
|
+
def _format_message(self, context: MiddlewareContext) -> str:
|
|
50
|
+
"""Format a message for logging."""
|
|
51
|
+
parts = [
|
|
52
|
+
f"source={context.source}",
|
|
53
|
+
f"type={context.type}",
|
|
54
|
+
f"method={context.method or 'unknown'}",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
if self.include_payloads and hasattr(context.message, "__dict__"):
|
|
58
|
+
try:
|
|
59
|
+
payload = json.dumps(context.message.__dict__, default=str)
|
|
60
|
+
if len(payload) > self.max_payload_length:
|
|
61
|
+
payload = payload[: self.max_payload_length] + "..."
|
|
62
|
+
parts.append(f"payload={payload}")
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
parts.append("payload=<non-serializable>")
|
|
65
|
+
|
|
66
|
+
return " ".join(parts)
|
|
67
|
+
|
|
68
|
+
async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
|
|
69
|
+
"""Log all messages."""
|
|
70
|
+
message_info = self._format_message(context)
|
|
71
|
+
|
|
72
|
+
self.logger.log(self.log_level, f"Processing message: {message_info}")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
result = await call_next(context)
|
|
76
|
+
self.logger.log(
|
|
77
|
+
self.log_level, f"Completed message: {context.method or 'unknown'}"
|
|
78
|
+
)
|
|
79
|
+
return result
|
|
80
|
+
except Exception as e:
|
|
81
|
+
self.logger.log(
|
|
82
|
+
logging.ERROR, f"Failed message: {context.method or 'unknown'} - {e}"
|
|
83
|
+
)
|
|
84
|
+
raise
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class StructuredLoggingMiddleware(Middleware):
|
|
88
|
+
"""Middleware that provides structured JSON logging for better log analysis.
|
|
89
|
+
|
|
90
|
+
Outputs structured logs that are easier to parse and analyze with log
|
|
91
|
+
aggregation tools like ELK stack, Splunk, or cloud logging services.
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
```python
|
|
95
|
+
from fastmcp.server.middleware.logging import StructuredLoggingMiddleware
|
|
96
|
+
import logging
|
|
97
|
+
|
|
98
|
+
mcp = FastMCP("MyServer")
|
|
99
|
+
mcp.add_middleware(StructuredLoggingMiddleware())
|
|
100
|
+
```
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
logger: logging.Logger | None = None,
|
|
106
|
+
log_level: int = logging.INFO,
|
|
107
|
+
include_payloads: bool = False,
|
|
108
|
+
):
|
|
109
|
+
"""Initialize structured logging middleware.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
logger: Logger instance to use. If None, creates a logger named 'fastmcp.structured'
|
|
113
|
+
log_level: Log level for messages (default: INFO)
|
|
114
|
+
include_payloads: Whether to include message payloads in logs
|
|
115
|
+
"""
|
|
116
|
+
self.logger = logger or logging.getLogger("fastmcp.structured")
|
|
117
|
+
self.log_level = log_level
|
|
118
|
+
self.include_payloads = include_payloads
|
|
119
|
+
|
|
120
|
+
def _create_log_entry(
|
|
121
|
+
self, context: MiddlewareContext, event: str, **extra_fields
|
|
122
|
+
) -> dict:
|
|
123
|
+
"""Create a structured log entry."""
|
|
124
|
+
entry = {
|
|
125
|
+
"event": event,
|
|
126
|
+
"timestamp": context.timestamp.isoformat(),
|
|
127
|
+
"source": context.source,
|
|
128
|
+
"type": context.type,
|
|
129
|
+
"method": context.method,
|
|
130
|
+
**extra_fields,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if self.include_payloads and hasattr(context.message, "__dict__"):
|
|
134
|
+
try:
|
|
135
|
+
entry["payload"] = context.message.__dict__
|
|
136
|
+
except (TypeError, ValueError):
|
|
137
|
+
entry["payload"] = "<non-serializable>"
|
|
138
|
+
|
|
139
|
+
return entry
|
|
140
|
+
|
|
141
|
+
async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
|
|
142
|
+
"""Log structured message information."""
|
|
143
|
+
start_entry = self._create_log_entry(context, "request_start")
|
|
144
|
+
self.logger.log(self.log_level, json.dumps(start_entry))
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
result = await call_next(context)
|
|
148
|
+
|
|
149
|
+
success_entry = self._create_log_entry(
|
|
150
|
+
context,
|
|
151
|
+
"request_success",
|
|
152
|
+
result_type=type(result).__name__ if result else None,
|
|
153
|
+
)
|
|
154
|
+
self.logger.log(self.log_level, json.dumps(success_entry))
|
|
155
|
+
|
|
156
|
+
return result
|
|
157
|
+
except Exception as e:
|
|
158
|
+
error_entry = self._create_log_entry(
|
|
159
|
+
context,
|
|
160
|
+
"request_error",
|
|
161
|
+
error_type=type(e).__name__,
|
|
162
|
+
error_message=str(e),
|
|
163
|
+
)
|
|
164
|
+
self.logger.log(logging.ERROR, json.dumps(error_entry))
|
|
165
|
+
raise
|