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.
Files changed (43) hide show
  1. fastmcp/cli/cli.py +99 -1
  2. fastmcp/cli/run.py +1 -3
  3. fastmcp/client/auth/oauth.py +1 -2
  4. fastmcp/client/client.py +23 -7
  5. fastmcp/client/logging.py +1 -2
  6. fastmcp/client/messages.py +126 -0
  7. fastmcp/client/transports.py +17 -2
  8. fastmcp/contrib/mcp_mixin/README.md +79 -2
  9. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -0
  10. fastmcp/prompts/prompt.py +109 -13
  11. fastmcp/prompts/prompt_manager.py +119 -43
  12. fastmcp/resources/resource.py +27 -1
  13. fastmcp/resources/resource_manager.py +249 -76
  14. fastmcp/resources/template.py +44 -2
  15. fastmcp/server/auth/providers/bearer.py +62 -13
  16. fastmcp/server/context.py +113 -10
  17. fastmcp/server/http.py +8 -0
  18. fastmcp/server/low_level.py +35 -0
  19. fastmcp/server/middleware/__init__.py +6 -0
  20. fastmcp/server/middleware/error_handling.py +206 -0
  21. fastmcp/server/middleware/logging.py +165 -0
  22. fastmcp/server/middleware/middleware.py +236 -0
  23. fastmcp/server/middleware/rate_limiting.py +231 -0
  24. fastmcp/server/middleware/timing.py +156 -0
  25. fastmcp/server/proxy.py +250 -140
  26. fastmcp/server/server.py +446 -280
  27. fastmcp/settings.py +2 -2
  28. fastmcp/tools/tool.py +22 -2
  29. fastmcp/tools/tool_manager.py +114 -45
  30. fastmcp/tools/tool_transform.py +42 -16
  31. fastmcp/utilities/components.py +22 -2
  32. fastmcp/utilities/inspect.py +326 -0
  33. fastmcp/utilities/json_schema.py +67 -23
  34. fastmcp/utilities/mcp_config.py +13 -7
  35. fastmcp/utilities/openapi.py +75 -5
  36. fastmcp/utilities/tests.py +1 -1
  37. fastmcp/utilities/types.py +90 -1
  38. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/METADATA +2 -2
  39. fastmcp-2.9.1.dist-info/RECORD +78 -0
  40. fastmcp-2.8.1.dist-info/RECORD +0 -69
  41. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/WHEEL +0 -0
  42. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/entry_points.txt +0 -0
  43. {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 __enter__(self) -> Context:
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 __exit__(self, exc_type, exc_val, exc_tb) -> None:
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
- return self.fastmcp._mcp_server.request_context
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.request_context.session.send_progress_notification(
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.request_context.session.send_log_message(
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 session(self):
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.request_context.session.list_roots()
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.request_context.session.create_message(
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,6 @@
1
+ from .middleware import Middleware, MiddlewareContext
2
+
3
+ __all__ = [
4
+ "Middleware",
5
+ "MiddlewareContext",
6
+ ]
@@ -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