fastmcp 2.8.1__py3-none-any.whl → 2.9.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 (38) 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 +21 -5
  5. fastmcp/client/transports.py +17 -2
  6. fastmcp/contrib/mcp_mixin/README.md +79 -2
  7. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -0
  8. fastmcp/prompts/prompt.py +91 -11
  9. fastmcp/prompts/prompt_manager.py +119 -43
  10. fastmcp/resources/resource.py +11 -1
  11. fastmcp/resources/resource_manager.py +249 -76
  12. fastmcp/resources/template.py +27 -1
  13. fastmcp/server/auth/providers/bearer.py +32 -10
  14. fastmcp/server/context.py +41 -2
  15. fastmcp/server/http.py +8 -0
  16. fastmcp/server/middleware/__init__.py +6 -0
  17. fastmcp/server/middleware/error_handling.py +206 -0
  18. fastmcp/server/middleware/logging.py +165 -0
  19. fastmcp/server/middleware/middleware.py +236 -0
  20. fastmcp/server/middleware/rate_limiting.py +231 -0
  21. fastmcp/server/middleware/timing.py +156 -0
  22. fastmcp/server/proxy.py +250 -140
  23. fastmcp/server/server.py +320 -242
  24. fastmcp/settings.py +2 -2
  25. fastmcp/tools/tool.py +6 -2
  26. fastmcp/tools/tool_manager.py +114 -45
  27. fastmcp/utilities/components.py +22 -2
  28. fastmcp/utilities/inspect.py +326 -0
  29. fastmcp/utilities/json_schema.py +67 -23
  30. fastmcp/utilities/mcp_config.py +13 -7
  31. fastmcp/utilities/openapi.py +5 -3
  32. fastmcp/utilities/tests.py +1 -1
  33. fastmcp/utilities/types.py +90 -1
  34. {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/METADATA +2 -2
  35. {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/RECORD +38 -31
  36. {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/WHEEL +0 -0
  37. {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/entry_points.txt +0 -0
  38. {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -62,6 +62,9 @@ class ResourceTemplate(FastMCPComponent):
62
62
  description="JSON schema for function parameters"
63
63
  )
64
64
 
65
+ def __repr__(self) -> str:
66
+ return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
67
+
65
68
  @staticmethod
66
69
  def from_function(
67
70
  fn: Callable[..., Any],
@@ -128,6 +131,29 @@ class ResourceTemplate(FastMCPComponent):
128
131
  }
129
132
  return MCPResourceTemplate(**kwargs | overrides)
130
133
 
134
+ @classmethod
135
+ def from_mcp_template(cls, mcp_template: MCPResourceTemplate) -> ResourceTemplate:
136
+ """Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object."""
137
+ # Note: This creates a simple ResourceTemplate instance. For function-based templates,
138
+ # the original function is lost, which is expected for remote templates.
139
+ return cls(
140
+ uri_template=mcp_template.uriTemplate,
141
+ name=mcp_template.name,
142
+ description=mcp_template.description,
143
+ mime_type=mcp_template.mimeType or "text/plain",
144
+ parameters={}, # Remote templates don't have local parameters
145
+ )
146
+
147
+ @property
148
+ def key(self) -> str:
149
+ """
150
+ The key of the component. This is used for internal bookkeeping
151
+ and may reflect e.g. prefixes or other identifiers. You should not depend on
152
+ keys having a certain value, as the same tool loaded from different
153
+ hierarchies of servers may have different keys.
154
+ """
155
+ return self._key or self.uri_template
156
+
131
157
 
132
158
  class FunctionResourceTemplate(ResourceTemplate):
133
159
  """A template for dynamically creating resources."""
@@ -214,7 +240,7 @@ class FunctionResourceTemplate(ResourceTemplate):
214
240
  f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
215
241
  )
216
242
 
217
- description = description or fn.__doc__
243
+ description = description or inspect.getdoc(fn)
218
244
 
219
245
  # if the fn is a callable class, we need to get the __call__ method from here out
220
246
  if not inspect.isroutine(fn):
@@ -17,7 +17,7 @@ from mcp.shared.auth import (
17
17
  OAuthClientInformationFull,
18
18
  OAuthToken,
19
19
  )
20
- from pydantic import SecretStr
20
+ from pydantic import AnyHttpUrl, SecretStr, ValidationError
21
21
 
22
22
  from fastmcp.server.auth.auth import (
23
23
  ClientRegistrationOptions,
@@ -89,7 +89,7 @@ class RSAKeyPair:
89
89
  self,
90
90
  subject: str = "fastmcp-user",
91
91
  issuer: str = "https://fastmcp.example.com",
92
- audience: str | None = None,
92
+ audience: str | list[str] | None = None,
93
93
  scopes: list[str] | None = None,
94
94
  expires_in_seconds: int = 3600,
95
95
  additional_claims: dict[str, Any] | None = None,
@@ -102,7 +102,7 @@ class RSAKeyPair:
102
102
  private_key_pem: RSA private key in PEM format
103
103
  subject: Subject claim (usually user ID)
104
104
  issuer: Issuer claim
105
- audience: Audience claim (optional)
105
+ audience: Audience claim - can be a string or list of strings (optional)
106
106
  scopes: List of scopes to include
107
107
  expires_in_seconds: Token expiration time in seconds
108
108
  additional_claims: Any additional claims to include
@@ -161,7 +161,7 @@ class BearerAuthProvider(OAuthProvider):
161
161
  public_key: str | None = None,
162
162
  jwks_uri: str | None = None,
163
163
  issuer: str | None = None,
164
- audience: str | None = None,
164
+ audience: str | list[str] | None = None,
165
165
  required_scopes: list[str] | None = None,
166
166
  ):
167
167
  """
@@ -171,7 +171,7 @@ class BearerAuthProvider(OAuthProvider):
171
171
  public_key: RSA public key in PEM format (for static key)
172
172
  jwks_uri: URI to fetch keys from (for key rotation)
173
173
  issuer: Expected issuer claim (optional)
174
- audience: Expected audience claim (optional)
174
+ audience: Expected audience claim - can be a string or list of strings (optional)
175
175
  required_scopes: List of required scopes for access (optional)
176
176
  """
177
177
  if not (public_key or jwks_uri):
@@ -179,8 +179,16 @@ class BearerAuthProvider(OAuthProvider):
179
179
  if public_key and jwks_uri:
180
180
  raise ValueError("Provide either public_key or jwks_uri, not both")
181
181
 
182
+ # Only pass issuer to parent if it's a valid URL, otherwise use default
183
+ # This allows the issuer claim validation to work with string issuers per RFC 7519
184
+ try:
185
+ issuer_url = AnyHttpUrl(issuer) if issuer else "https://fastmcp.example.com"
186
+ except ValidationError:
187
+ # Issuer is not a valid URL, use default for parent class
188
+ issuer_url = "https://fastmcp.example.com"
189
+
182
190
  super().__init__(
183
- issuer_url=issuer or "https://fastmcp.example.com",
191
+ issuer_url=issuer_url,
184
192
  client_registration_options=ClientRegistrationOptions(enabled=False),
185
193
  revocation_options=RevocationOptions(enabled=False),
186
194
  required_scopes=required_scopes,
@@ -304,11 +312,25 @@ class BearerAuthProvider(OAuthProvider):
304
312
  # Validate audience if configured
305
313
  if self.audience:
306
314
  aud = claims.get("aud")
307
- if isinstance(aud, list):
308
- if self.audience not in aud:
315
+
316
+ # Handle different combinations of audience types
317
+ if isinstance(self.audience, list):
318
+ # self.audience is a list - check if any expected audience is present
319
+ if isinstance(aud, list):
320
+ # Both are lists - check for intersection
321
+ if not any(expected in aud for expected in self.audience):
322
+ return None
323
+ else:
324
+ # aud is a string - check if it's in our expected list
325
+ if aud not in self.audience:
326
+ return None
327
+ else:
328
+ # self.audience is a string - use original logic
329
+ if isinstance(aud, list):
330
+ if self.audience not in aud:
331
+ return None
332
+ elif aud != self.audience:
309
333
  return None
310
- elif aud != self.audience:
311
- return None
312
334
 
313
335
  # Extract claims - prefer client_id over sub for OAuth application identification
314
336
  client_id = claims.get("client_id") or claims.get("sub") or "unknown"
fastmcp/server/context.py CHANGED
@@ -8,6 +8,7 @@ from dataclasses import dataclass
8
8
 
9
9
  from mcp import LoggingLevel
10
10
  from mcp.server.lowlevel.helper_types import ReadResourceContents
11
+ from mcp.server.lowlevel.server import request_ctx
11
12
  from mcp.shared.context import RequestContext
12
13
  from mcp.types import (
13
14
  CreateMessageResult,
@@ -95,8 +96,14 @@ class Context:
95
96
 
96
97
  @property
97
98
  def request_context(self) -> RequestContext:
98
- """Access to the underlying request context."""
99
- return self.fastmcp._mcp_server.request_context
99
+ """Access to the underlying request context.
100
+
101
+ If called outside of a request context, this will raise a ValueError.
102
+ """
103
+ try:
104
+ return request_ctx.get()
105
+ except LookupError:
106
+ raise ValueError("Context is not available outside of a request")
100
107
 
101
108
  async def report_progress(
102
109
  self, progress: float, total: float | None = None, message: str | None = None
@@ -122,6 +129,7 @@ class Context:
122
129
  progress=progress,
123
130
  total=total,
124
131
  message=message,
132
+ related_request_id=self.request_id,
125
133
  )
126
134
 
127
135
  async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]:
@@ -170,6 +178,37 @@ class Context:
170
178
  """Get the unique ID for this request."""
171
179
  return str(self.request_context.request_id)
172
180
 
181
+ @property
182
+ def session_id(self) -> str | None:
183
+ """Get the MCP session ID for HTTP transports.
184
+
185
+ Returns the session ID that can be used as a key for session-based
186
+ data storage (e.g., Redis) to share data between tool calls within
187
+ the same client session.
188
+
189
+ Returns:
190
+ The session ID for HTTP transports (SSE, StreamableHTTP), or None
191
+ for stdio and in-memory transports which don't use session IDs.
192
+
193
+ Example:
194
+ ```python
195
+ @server.tool
196
+ def store_data(data: dict, ctx: Context) -> str:
197
+ if session_id := ctx.session_id:
198
+ redis_client.set(f"session:{session_id}:data", json.dumps(data))
199
+ return f"Data stored for session {session_id}"
200
+ return "No session ID available (stdio/memory transport)"
201
+ ```
202
+ """
203
+ try:
204
+ from fastmcp.server.dependencies import get_http_headers
205
+
206
+ headers = get_http_headers(include_all=True)
207
+ return headers.get("mcp-session-id")
208
+ except RuntimeError:
209
+ # No HTTP context available (stdio/in-memory transport)
210
+ return None
211
+
173
212
  @property
174
213
  def session(self):
175
214
  """Access to the underlying session for advanced usage."""
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,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