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.
- fastmcp/cli/cli.py +99 -1
- fastmcp/cli/run.py +1 -3
- fastmcp/client/auth/oauth.py +1 -2
- fastmcp/client/client.py +21 -5
- 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 +91 -11
- fastmcp/prompts/prompt_manager.py +119 -43
- fastmcp/resources/resource.py +11 -1
- fastmcp/resources/resource_manager.py +249 -76
- fastmcp/resources/template.py +27 -1
- fastmcp/server/auth/providers/bearer.py +32 -10
- fastmcp/server/context.py +41 -2
- fastmcp/server/http.py +8 -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 +320 -242
- fastmcp/settings.py +2 -2
- fastmcp/tools/tool.py +6 -2
- fastmcp/tools/tool_manager.py +114 -45
- 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 +5 -3
- fastmcp/utilities/tests.py +1 -1
- fastmcp/utilities/types.py +90 -1
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/METADATA +2 -2
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/RECORD +38 -31
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/resources/template.py
CHANGED
|
@@ -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
|
|
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=
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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,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
|