fastmcp 2.12.2__py3-none-any.whl → 2.12.4__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/claude.py +1 -10
- fastmcp/cli/cli.py +45 -25
- fastmcp/cli/install/__init__.py +2 -0
- fastmcp/cli/install/claude_code.py +1 -10
- fastmcp/cli/install/claude_desktop.py +1 -9
- fastmcp/cli/install/cursor.py +2 -18
- fastmcp/cli/install/gemini_cli.py +241 -0
- fastmcp/cli/install/mcp_json.py +1 -9
- fastmcp/cli/run.py +2 -86
- fastmcp/client/auth/oauth.py +50 -37
- fastmcp/client/client.py +18 -8
- fastmcp/client/elicitation.py +6 -1
- fastmcp/client/transports.py +1 -1
- fastmcp/contrib/component_manager/component_service.py +1 -1
- fastmcp/contrib/mcp_mixin/README.md +3 -3
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +41 -6
- fastmcp/experimental/utilities/openapi/director.py +8 -1
- fastmcp/experimental/utilities/openapi/schemas.py +31 -5
- fastmcp/prompts/prompt.py +10 -8
- fastmcp/resources/resource.py +14 -11
- fastmcp/resources/template.py +12 -10
- fastmcp/server/auth/auth.py +10 -4
- fastmcp/server/auth/oauth_proxy.py +93 -23
- fastmcp/server/auth/oidc_proxy.py +348 -0
- fastmcp/server/auth/providers/auth0.py +174 -0
- fastmcp/server/auth/providers/aws.py +237 -0
- fastmcp/server/auth/providers/azure.py +6 -2
- fastmcp/server/auth/providers/descope.py +172 -0
- fastmcp/server/auth/providers/github.py +6 -2
- fastmcp/server/auth/providers/google.py +6 -2
- fastmcp/server/auth/providers/workos.py +6 -2
- fastmcp/server/context.py +17 -16
- fastmcp/server/dependencies.py +18 -5
- fastmcp/server/http.py +1 -1
- fastmcp/server/middleware/logging.py +147 -116
- fastmcp/server/middleware/middleware.py +3 -2
- fastmcp/server/openapi.py +5 -1
- fastmcp/server/server.py +43 -36
- fastmcp/settings.py +42 -6
- fastmcp/tools/tool.py +105 -87
- fastmcp/tools/tool_transform.py +1 -1
- fastmcp/utilities/json_schema.py +18 -1
- fastmcp/utilities/logging.py +66 -4
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +4 -39
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -2
- fastmcp/utilities/mcp_server_config/v1/schema.json +2 -1
- fastmcp/utilities/storage.py +204 -0
- fastmcp/utilities/tests.py +8 -6
- fastmcp/utilities/types.py +9 -5
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/METADATA +121 -48
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/RECORD +54 -48
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/WHEEL +0 -0
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -32,6 +32,7 @@ from fastmcp.server.auth.auth import AccessToken
|
|
|
32
32
|
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
|
33
33
|
from fastmcp.utilities.auth import parse_scopes
|
|
34
34
|
from fastmcp.utilities.logging import get_logger
|
|
35
|
+
from fastmcp.utilities.storage import KVStorage
|
|
35
36
|
from fastmcp.utilities.types import NotSet, NotSetT
|
|
36
37
|
|
|
37
38
|
logger = get_logger(__name__)
|
|
@@ -217,6 +218,7 @@ class GoogleProvider(OAuthProxy):
|
|
|
217
218
|
required_scopes: list[str] | NotSetT = NotSet,
|
|
218
219
|
timeout_seconds: int | NotSetT = NotSet,
|
|
219
220
|
allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
|
|
221
|
+
client_storage: KVStorage | None = None,
|
|
220
222
|
):
|
|
221
223
|
"""Initialize Google OAuth provider.
|
|
222
224
|
|
|
@@ -232,6 +234,8 @@ class GoogleProvider(OAuthProxy):
|
|
|
232
234
|
timeout_seconds: HTTP request timeout for Google API calls
|
|
233
235
|
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
|
234
236
|
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
|
237
|
+
client_storage: Storage implementation for OAuth client registrations.
|
|
238
|
+
Defaults to file-based storage if not specified.
|
|
235
239
|
"""
|
|
236
240
|
|
|
237
241
|
settings = GoogleProviderSettings.model_validate(
|
|
@@ -261,7 +265,6 @@ class GoogleProvider(OAuthProxy):
|
|
|
261
265
|
)
|
|
262
266
|
|
|
263
267
|
# Apply defaults
|
|
264
|
-
redirect_path_final = settings.redirect_path or "/auth/callback"
|
|
265
268
|
timeout_seconds_final = settings.timeout_seconds or 10
|
|
266
269
|
# Google requires at least one scope - openid is the minimal OIDC scope
|
|
267
270
|
required_scopes_final = settings.required_scopes or ["openid"]
|
|
@@ -286,9 +289,10 @@ class GoogleProvider(OAuthProxy):
|
|
|
286
289
|
upstream_client_secret=client_secret_str,
|
|
287
290
|
token_verifier=token_verifier,
|
|
288
291
|
base_url=settings.base_url,
|
|
289
|
-
redirect_path=
|
|
292
|
+
redirect_path=settings.redirect_path,
|
|
290
293
|
issuer_url=settings.base_url, # We act as the issuer for client registration
|
|
291
294
|
allowed_client_redirect_uris=allowed_client_redirect_uris_final,
|
|
295
|
+
client_storage=client_storage,
|
|
292
296
|
)
|
|
293
297
|
|
|
294
298
|
logger.info(
|
|
@@ -23,6 +23,7 @@ from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
|
|
23
23
|
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
|
24
24
|
from fastmcp.utilities.auth import parse_scopes
|
|
25
25
|
from fastmcp.utilities.logging import get_logger
|
|
26
|
+
from fastmcp.utilities.storage import KVStorage
|
|
26
27
|
from fastmcp.utilities.types import NotSet, NotSetT
|
|
27
28
|
|
|
28
29
|
logger = get_logger(__name__)
|
|
@@ -169,6 +170,7 @@ class WorkOSProvider(OAuthProxy):
|
|
|
169
170
|
required_scopes: list[str] | None | NotSetT = NotSet,
|
|
170
171
|
timeout_seconds: int | NotSetT = NotSet,
|
|
171
172
|
allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
|
|
173
|
+
client_storage: KVStorage | None = None,
|
|
172
174
|
):
|
|
173
175
|
"""Initialize WorkOS OAuth provider.
|
|
174
176
|
|
|
@@ -182,6 +184,8 @@ class WorkOSProvider(OAuthProxy):
|
|
|
182
184
|
timeout_seconds: HTTP request timeout for WorkOS API calls
|
|
183
185
|
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
|
184
186
|
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
|
187
|
+
client_storage: Storage implementation for OAuth client registrations.
|
|
188
|
+
Defaults to file-based storage if not specified.
|
|
185
189
|
"""
|
|
186
190
|
|
|
187
191
|
settings = WorkOSProviderSettings.model_validate(
|
|
@@ -220,7 +224,6 @@ class WorkOSProvider(OAuthProxy):
|
|
|
220
224
|
if not authkit_domain_str.startswith(("http://", "https://")):
|
|
221
225
|
authkit_domain_str = f"https://{authkit_domain_str}"
|
|
222
226
|
authkit_domain_final = authkit_domain_str.rstrip("/")
|
|
223
|
-
redirect_path_final = settings.redirect_path or "/auth/callback"
|
|
224
227
|
timeout_seconds_final = settings.timeout_seconds or 10
|
|
225
228
|
scopes_final = settings.required_scopes or []
|
|
226
229
|
allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
|
|
@@ -245,9 +248,10 @@ class WorkOSProvider(OAuthProxy):
|
|
|
245
248
|
upstream_client_secret=client_secret_str,
|
|
246
249
|
token_verifier=token_verifier,
|
|
247
250
|
base_url=settings.base_url,
|
|
248
|
-
redirect_path=
|
|
251
|
+
redirect_path=settings.redirect_path,
|
|
249
252
|
issuer_url=settings.base_url,
|
|
250
253
|
allowed_client_redirect_uris=allowed_client_redirect_uris_final,
|
|
254
|
+
client_storage=client_storage,
|
|
251
255
|
)
|
|
252
256
|
|
|
253
257
|
logger.info(
|
fastmcp/server/context.py
CHANGED
|
@@ -5,7 +5,7 @@ import copy
|
|
|
5
5
|
import inspect
|
|
6
6
|
import warnings
|
|
7
7
|
import weakref
|
|
8
|
-
from collections.abc import Generator, Mapping
|
|
8
|
+
from collections.abc import Generator, Mapping, Sequence
|
|
9
9
|
from contextlib import contextmanager
|
|
10
10
|
from contextvars import ContextVar, Token
|
|
11
11
|
from dataclasses import dataclass
|
|
@@ -17,9 +17,10 @@ from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
|
17
17
|
from mcp.server.lowlevel.server import request_ctx
|
|
18
18
|
from mcp.shared.context import RequestContext
|
|
19
19
|
from mcp.types import (
|
|
20
|
+
AudioContent,
|
|
20
21
|
ClientCapabilities,
|
|
21
|
-
ContentBlock,
|
|
22
22
|
CreateMessageResult,
|
|
23
|
+
ImageContent,
|
|
23
24
|
IncludeContext,
|
|
24
25
|
ModelHint,
|
|
25
26
|
ModelPreferences,
|
|
@@ -85,18 +86,18 @@ class Context:
|
|
|
85
86
|
|
|
86
87
|
```python
|
|
87
88
|
@server.tool
|
|
88
|
-
def my_tool(x: int, ctx: Context) -> str:
|
|
89
|
+
async def my_tool(x: int, ctx: Context) -> str:
|
|
89
90
|
# Log messages to the client
|
|
90
|
-
ctx.info(f"Processing {x}")
|
|
91
|
-
ctx.debug("Debug info")
|
|
92
|
-
ctx.warning("Warning message")
|
|
93
|
-
ctx.error("Error message")
|
|
91
|
+
await ctx.info(f"Processing {x}")
|
|
92
|
+
await ctx.debug("Debug info")
|
|
93
|
+
await ctx.warning("Warning message")
|
|
94
|
+
await ctx.error("Error message")
|
|
94
95
|
|
|
95
96
|
# Report progress
|
|
96
|
-
ctx.report_progress(50, 100, "Processing")
|
|
97
|
+
await ctx.report_progress(50, 100, "Processing")
|
|
97
98
|
|
|
98
99
|
# Access resources
|
|
99
|
-
data = ctx.read_resource("resource://data")
|
|
100
|
+
data = await ctx.read_resource("resource://data")
|
|
100
101
|
|
|
101
102
|
# Get request info
|
|
102
103
|
request_id = ctx.request_id
|
|
@@ -359,13 +360,13 @@ class Context:
|
|
|
359
360
|
|
|
360
361
|
async def sample(
|
|
361
362
|
self,
|
|
362
|
-
messages: str |
|
|
363
|
+
messages: str | Sequence[str | SamplingMessage],
|
|
363
364
|
system_prompt: str | None = None,
|
|
364
365
|
include_context: IncludeContext | None = None,
|
|
365
366
|
temperature: float | None = None,
|
|
366
367
|
max_tokens: int | None = None,
|
|
367
368
|
model_preferences: ModelPreferences | str | list[str] | None = None,
|
|
368
|
-
) ->
|
|
369
|
+
) -> TextContent | ImageContent | AudioContent:
|
|
369
370
|
"""
|
|
370
371
|
Send a sampling request to the client and await the response.
|
|
371
372
|
|
|
@@ -383,7 +384,7 @@ class Context:
|
|
|
383
384
|
content=TextContent(text=messages, type="text"), role="user"
|
|
384
385
|
)
|
|
385
386
|
]
|
|
386
|
-
elif isinstance(messages,
|
|
387
|
+
elif isinstance(messages, Sequence):
|
|
387
388
|
sampling_messages = [
|
|
388
389
|
SamplingMessage(content=TextContent(text=m, type="text"), role="user")
|
|
389
390
|
if isinstance(m, str)
|
|
@@ -449,7 +450,7 @@ class Context:
|
|
|
449
450
|
AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation
|
|
450
451
|
): ...
|
|
451
452
|
|
|
452
|
-
"""When response_type is None, the accepted
|
|
453
|
+
"""When response_type is None, the accepted elicitation will contain an
|
|
453
454
|
empty dict"""
|
|
454
455
|
|
|
455
456
|
@overload
|
|
@@ -459,7 +460,7 @@ class Context:
|
|
|
459
460
|
response_type: type[T],
|
|
460
461
|
) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ...
|
|
461
462
|
|
|
462
|
-
"""When response_type is not None, the accepted
|
|
463
|
+
"""When response_type is not None, the accepted elicitation will contain the
|
|
463
464
|
response data"""
|
|
464
465
|
|
|
465
466
|
@overload
|
|
@@ -469,7 +470,7 @@ class Context:
|
|
|
469
470
|
response_type: list[str],
|
|
470
471
|
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
|
|
471
472
|
|
|
472
|
-
"""When response_type is a list of strings, the accepted
|
|
473
|
+
"""When response_type is a list of strings, the accepted elicitation will
|
|
473
474
|
contain the selected string response"""
|
|
474
475
|
|
|
475
476
|
async def elicit(
|
|
@@ -573,7 +574,7 @@ class Context:
|
|
|
573
574
|
warnings.warn(
|
|
574
575
|
"Context.get_http_request() is deprecated and will be removed in a future version. "
|
|
575
576
|
"Use get_http_request() from fastmcp.server.dependencies instead. "
|
|
576
|
-
"See https://gofastmcp.com/
|
|
577
|
+
"See https://gofastmcp.com/servers/context#http-requests for more details.",
|
|
577
578
|
DeprecationWarning,
|
|
578
579
|
stacklevel=2,
|
|
579
580
|
)
|
fastmcp/server/dependencies.py
CHANGED
|
@@ -5,6 +5,9 @@ from typing import TYPE_CHECKING
|
|
|
5
5
|
from mcp.server.auth.middleware.auth_context import (
|
|
6
6
|
get_access_token as _sdk_get_access_token,
|
|
7
7
|
)
|
|
8
|
+
from mcp.server.auth.provider import (
|
|
9
|
+
AccessToken as _SDKAccessToken,
|
|
10
|
+
)
|
|
8
11
|
from starlette.requests import Request
|
|
9
12
|
|
|
10
13
|
from fastmcp.server.auth import AccessToken
|
|
@@ -107,17 +110,27 @@ def get_access_token() -> AccessToken | None:
|
|
|
107
110
|
The access token if an authenticated user is available, None otherwise.
|
|
108
111
|
"""
|
|
109
112
|
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
+
access_token: _SDKAccessToken | None = _sdk_get_access_token()
|
|
114
|
+
|
|
115
|
+
if access_token is None or isinstance(access_token, AccessToken):
|
|
116
|
+
return access_token
|
|
113
117
|
|
|
114
118
|
# If the object is not a FastMCP AccessToken, convert it to one if the fields are compatible
|
|
115
119
|
# This is a workaround for the case where the SDK returns a different type
|
|
116
120
|
# If it fails, it will raise a TypeError
|
|
117
121
|
try:
|
|
118
|
-
|
|
122
|
+
access_token_as_dict = access_token.model_dump()
|
|
123
|
+
return AccessToken(
|
|
124
|
+
token=access_token_as_dict["token"],
|
|
125
|
+
client_id=access_token_as_dict["client_id"],
|
|
126
|
+
scopes=access_token_as_dict["scopes"],
|
|
127
|
+
# Optional fields
|
|
128
|
+
expires_at=access_token_as_dict.get("expires_at"),
|
|
129
|
+
resource_owner=access_token_as_dict.get("resource_owner"),
|
|
130
|
+
claims=access_token_as_dict.get("claims"),
|
|
131
|
+
)
|
|
119
132
|
except Exception as e:
|
|
120
133
|
raise TypeError(
|
|
121
|
-
f"Expected fastmcp.server.auth.auth.AccessToken, got {type(
|
|
134
|
+
f"Expected fastmcp.server.auth.auth.AccessToken, got {type(access_token).__name__}. "
|
|
122
135
|
"Ensure the SDK is using the correct AccessToken type."
|
|
123
136
|
) from e
|
fastmcp/server/http.py
CHANGED
|
@@ -113,7 +113,7 @@ def create_base_app(
|
|
|
113
113
|
A Starlette application
|
|
114
114
|
"""
|
|
115
115
|
# Always add RequestContextMiddleware as the outermost middleware
|
|
116
|
-
middleware.
|
|
116
|
+
middleware.insert(0, Middleware(RequestContextMiddleware))
|
|
117
117
|
|
|
118
118
|
return StarletteWithLifespan(
|
|
119
119
|
routes=routes,
|
|
@@ -16,7 +16,131 @@ def default_serializer(data: Any) -> str:
|
|
|
16
16
|
return pydantic_core.to_json(data, fallback=str).decode()
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
class
|
|
19
|
+
class BaseLoggingMiddleware(Middleware):
|
|
20
|
+
"""Base class for logging middleware."""
|
|
21
|
+
|
|
22
|
+
logger: Logger
|
|
23
|
+
log_level: int
|
|
24
|
+
include_payloads: bool
|
|
25
|
+
include_payload_length: bool
|
|
26
|
+
estimate_payload_tokens: bool
|
|
27
|
+
max_payload_length: int | None
|
|
28
|
+
methods: list[str] | None
|
|
29
|
+
structured_logging: bool
|
|
30
|
+
payload_serializer: Callable[[Any], str] | None
|
|
31
|
+
|
|
32
|
+
def _serialize_payload(self, context: MiddlewareContext[Any]) -> str:
|
|
33
|
+
payload: str
|
|
34
|
+
|
|
35
|
+
if not self.payload_serializer:
|
|
36
|
+
payload = default_serializer(context.message)
|
|
37
|
+
else:
|
|
38
|
+
try:
|
|
39
|
+
payload = self.payload_serializer(context.message)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
self.logger.warning(
|
|
42
|
+
f"Failed to serialize payload due to {e}: {context.type} {context.method} {context.source}."
|
|
43
|
+
)
|
|
44
|
+
payload = default_serializer(context.message)
|
|
45
|
+
|
|
46
|
+
return payload
|
|
47
|
+
|
|
48
|
+
def _format_message(self, message: dict[str, str | int]) -> str:
|
|
49
|
+
"""Format a message for logging."""
|
|
50
|
+
if self.structured_logging:
|
|
51
|
+
return json.dumps(message)
|
|
52
|
+
else:
|
|
53
|
+
return " ".join([f"{k}={v}" for k, v in message.items()])
|
|
54
|
+
|
|
55
|
+
def _get_timestamp_from_context(self, context: MiddlewareContext[Any]) -> str:
|
|
56
|
+
"""Get a timestamp from the context."""
|
|
57
|
+
return context.timestamp.isoformat()
|
|
58
|
+
|
|
59
|
+
def _create_before_message(
|
|
60
|
+
self, context: MiddlewareContext[Any], event: str
|
|
61
|
+
) -> dict[str, str | int]:
|
|
62
|
+
message = self._create_base_message(context, event)
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
self.include_payloads
|
|
66
|
+
or self.include_payload_length
|
|
67
|
+
or self.estimate_payload_tokens
|
|
68
|
+
):
|
|
69
|
+
payload = self._serialize_payload(context)
|
|
70
|
+
|
|
71
|
+
if self.include_payload_length or self.estimate_payload_tokens:
|
|
72
|
+
payload_length = len(payload)
|
|
73
|
+
payload_tokens = payload_length // 4
|
|
74
|
+
if self.estimate_payload_tokens:
|
|
75
|
+
message["payload_tokens"] = payload_tokens
|
|
76
|
+
if self.include_payload_length:
|
|
77
|
+
message["payload_length"] = payload_length
|
|
78
|
+
|
|
79
|
+
if self.max_payload_length and len(payload) > self.max_payload_length:
|
|
80
|
+
payload = payload[: self.max_payload_length] + "..."
|
|
81
|
+
|
|
82
|
+
if self.include_payloads:
|
|
83
|
+
message["payload"] = payload
|
|
84
|
+
message["payload_type"] = type(context.message).__name__
|
|
85
|
+
|
|
86
|
+
return message
|
|
87
|
+
|
|
88
|
+
def _create_after_message(
|
|
89
|
+
self, context: MiddlewareContext[Any], event: str
|
|
90
|
+
) -> dict[str, str | int]:
|
|
91
|
+
return self._create_base_message(context, event)
|
|
92
|
+
|
|
93
|
+
def _create_base_message(
|
|
94
|
+
self,
|
|
95
|
+
context: MiddlewareContext[Any],
|
|
96
|
+
event: str,
|
|
97
|
+
) -> dict[str, str | int]:
|
|
98
|
+
"""Format a message for logging."""
|
|
99
|
+
|
|
100
|
+
parts: dict[str, str | int] = {
|
|
101
|
+
"event": event,
|
|
102
|
+
"timestamp": self._get_timestamp_from_context(context),
|
|
103
|
+
"method": context.method or "unknown",
|
|
104
|
+
"type": context.type,
|
|
105
|
+
"source": context.source,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return parts
|
|
109
|
+
|
|
110
|
+
async def on_message(
|
|
111
|
+
self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]
|
|
112
|
+
) -> Any:
|
|
113
|
+
"""Log all messages."""
|
|
114
|
+
|
|
115
|
+
if self.methods and context.method not in self.methods:
|
|
116
|
+
return await call_next(context)
|
|
117
|
+
|
|
118
|
+
request_start_log_message = self._create_before_message(
|
|
119
|
+
context, "request_start"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
formatted_message = self._format_message(request_start_log_message)
|
|
123
|
+
self.logger.log(self.log_level, f"Processing message: {formatted_message}")
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
result = await call_next(context)
|
|
127
|
+
|
|
128
|
+
request_success_log_message = self._create_after_message(
|
|
129
|
+
context, "request_success"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
formatted_message = self._format_message(request_success_log_message)
|
|
133
|
+
self.logger.log(self.log_level, f"Completed message: {formatted_message}")
|
|
134
|
+
|
|
135
|
+
return result
|
|
136
|
+
except Exception as e:
|
|
137
|
+
self.logger.log(
|
|
138
|
+
logging.ERROR, f"Failed message: {context.method or 'unknown'} - {e}"
|
|
139
|
+
)
|
|
140
|
+
raise
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class LoggingMiddleware(BaseLoggingMiddleware):
|
|
20
144
|
"""Middleware that provides comprehensive request and response logging.
|
|
21
145
|
|
|
22
146
|
Logs all MCP messages with configurable detail levels. Useful for debugging,
|
|
@@ -37,9 +161,12 @@ class LoggingMiddleware(Middleware):
|
|
|
37
161
|
|
|
38
162
|
def __init__(
|
|
39
163
|
self,
|
|
164
|
+
*,
|
|
40
165
|
logger: logging.Logger | None = None,
|
|
41
166
|
log_level: int = logging.INFO,
|
|
42
167
|
include_payloads: bool = False,
|
|
168
|
+
include_payload_length: bool = False,
|
|
169
|
+
estimate_payload_tokens: bool = False,
|
|
43
170
|
max_payload_length: int = 1000,
|
|
44
171
|
methods: list[str] | None = None,
|
|
45
172
|
payload_serializer: Callable[[Any], str] | None = None,
|
|
@@ -50,68 +177,25 @@ class LoggingMiddleware(Middleware):
|
|
|
50
177
|
logger: Logger instance to use. If None, creates a logger named 'fastmcp.requests'
|
|
51
178
|
log_level: Log level for messages (default: INFO)
|
|
52
179
|
include_payloads: Whether to include message payloads in logs
|
|
180
|
+
include_payload_length: Whether to include response size in logs
|
|
181
|
+
estimate_payload_tokens: Whether to estimate response tokens
|
|
53
182
|
max_payload_length: Maximum length of payload to log (prevents huge logs)
|
|
54
183
|
methods: List of methods to log. If None, logs all methods.
|
|
184
|
+
payload_serializer: Callable that converts objects to a JSON string for the
|
|
185
|
+
payload. If not provided, uses FastMCP's default tool serializer.
|
|
55
186
|
"""
|
|
56
187
|
self.logger: Logger = logger or logging.getLogger("fastmcp.requests")
|
|
57
|
-
self.log_level
|
|
188
|
+
self.log_level = log_level
|
|
58
189
|
self.include_payloads: bool = include_payloads
|
|
190
|
+
self.include_payload_length: bool = include_payload_length
|
|
191
|
+
self.estimate_payload_tokens: bool = estimate_payload_tokens
|
|
59
192
|
self.max_payload_length: int = max_payload_length
|
|
60
193
|
self.methods: list[str] | None = methods
|
|
61
194
|
self.payload_serializer: Callable[[Any], str] | None = payload_serializer
|
|
195
|
+
self.structured_logging: bool = False
|
|
62
196
|
|
|
63
|
-
def _format_message(self, context: MiddlewareContext[Any]) -> str:
|
|
64
|
-
"""Format a message for logging."""
|
|
65
|
-
parts = [
|
|
66
|
-
f"source={context.source}",
|
|
67
|
-
f"type={context.type}",
|
|
68
|
-
f"method={context.method or 'unknown'}",
|
|
69
|
-
]
|
|
70
|
-
|
|
71
|
-
if self.include_payloads:
|
|
72
|
-
payload: str
|
|
73
|
-
|
|
74
|
-
if not self.payload_serializer:
|
|
75
|
-
payload = default_serializer(context.message)
|
|
76
|
-
else:
|
|
77
|
-
try:
|
|
78
|
-
payload = self.payload_serializer(context.message)
|
|
79
|
-
except Exception as e:
|
|
80
|
-
self.logger.warning(
|
|
81
|
-
f"Failed {e} to serialize payload: {context.type} {context.method} {context.source}."
|
|
82
|
-
)
|
|
83
|
-
payload = default_serializer(context.message)
|
|
84
|
-
|
|
85
|
-
if len(payload) > self.max_payload_length:
|
|
86
|
-
payload = payload[: self.max_payload_length] + "..."
|
|
87
197
|
|
|
88
|
-
|
|
89
|
-
return " ".join(parts)
|
|
90
|
-
|
|
91
|
-
async def on_message(
|
|
92
|
-
self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]
|
|
93
|
-
) -> Any:
|
|
94
|
-
"""Log all messages."""
|
|
95
|
-
message_info = self._format_message(context)
|
|
96
|
-
if self.methods and context.method not in self.methods:
|
|
97
|
-
return await call_next(context)
|
|
98
|
-
|
|
99
|
-
self.logger.log(self.log_level, f"Processing message: {message_info}")
|
|
100
|
-
|
|
101
|
-
try:
|
|
102
|
-
result = await call_next(context)
|
|
103
|
-
self.logger.log(
|
|
104
|
-
self.log_level, f"Completed message: {context.method or 'unknown'}"
|
|
105
|
-
)
|
|
106
|
-
return result
|
|
107
|
-
except Exception as e:
|
|
108
|
-
self.logger.log(
|
|
109
|
-
logging.ERROR, f"Failed message: {context.method or 'unknown'} - {e}"
|
|
110
|
-
)
|
|
111
|
-
raise
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
class StructuredLoggingMiddleware(Middleware):
|
|
198
|
+
class StructuredLoggingMiddleware(BaseLoggingMiddleware):
|
|
115
199
|
"""Middleware that provides structured JSON logging for better log analysis.
|
|
116
200
|
|
|
117
201
|
Outputs structured logs that are easier to parse and analyze with log
|
|
@@ -129,9 +213,12 @@ class StructuredLoggingMiddleware(Middleware):
|
|
|
129
213
|
|
|
130
214
|
def __init__(
|
|
131
215
|
self,
|
|
216
|
+
*,
|
|
132
217
|
logger: logging.Logger | None = None,
|
|
133
218
|
log_level: int = logging.INFO,
|
|
134
219
|
include_payloads: bool = False,
|
|
220
|
+
include_payload_length: bool = False,
|
|
221
|
+
estimate_payload_tokens: bool = False,
|
|
135
222
|
methods: list[str] | None = None,
|
|
136
223
|
payload_serializer: Callable[[Any], str] | None = None,
|
|
137
224
|
):
|
|
@@ -141,74 +228,18 @@ class StructuredLoggingMiddleware(Middleware):
|
|
|
141
228
|
logger: Logger instance to use. If None, creates a logger named 'fastmcp.structured'
|
|
142
229
|
log_level: Log level for messages (default: INFO)
|
|
143
230
|
include_payloads: Whether to include message payloads in logs
|
|
231
|
+
include_payload_length: Whether to include payload size in logs
|
|
232
|
+
estimate_payload_tokens: Whether to estimate token count using length // 4
|
|
144
233
|
methods: List of methods to log. If None, logs all methods.
|
|
145
|
-
|
|
234
|
+
payload_serializer: Callable that converts objects to a JSON string for the
|
|
146
235
|
payload. If not provided, uses FastMCP's default tool serializer.
|
|
147
236
|
"""
|
|
148
237
|
self.logger: Logger = logger or logging.getLogger("fastmcp.structured")
|
|
149
238
|
self.log_level: int = log_level
|
|
150
239
|
self.include_payloads: bool = include_payloads
|
|
240
|
+
self.include_payload_length: bool = include_payload_length
|
|
241
|
+
self.estimate_payload_tokens: bool = estimate_payload_tokens
|
|
151
242
|
self.methods: list[str] | None = methods
|
|
152
243
|
self.payload_serializer: Callable[[Any], str] | None = payload_serializer
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
self, context: MiddlewareContext[Any], event: str, **extra_fields: Any
|
|
156
|
-
) -> dict[str, Any]:
|
|
157
|
-
"""Create a structured log entry."""
|
|
158
|
-
entry = {
|
|
159
|
-
"event": event,
|
|
160
|
-
"timestamp": context.timestamp.isoformat(),
|
|
161
|
-
"source": context.source,
|
|
162
|
-
"type": context.type,
|
|
163
|
-
"method": context.method,
|
|
164
|
-
**extra_fields,
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if self.include_payloads:
|
|
168
|
-
payload: str
|
|
169
|
-
|
|
170
|
-
if not self.payload_serializer:
|
|
171
|
-
payload = default_serializer(context.message)
|
|
172
|
-
else:
|
|
173
|
-
try:
|
|
174
|
-
payload = self.payload_serializer(context.message)
|
|
175
|
-
except Exception as e:
|
|
176
|
-
self.logger.warning(
|
|
177
|
-
f"Failed {str(e)} to serialize payload: {context.type} {context.method} {context.source}."
|
|
178
|
-
)
|
|
179
|
-
payload = default_serializer(context.message)
|
|
180
|
-
|
|
181
|
-
entry["payload"] = payload
|
|
182
|
-
|
|
183
|
-
return entry
|
|
184
|
-
|
|
185
|
-
async def on_message(
|
|
186
|
-
self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]
|
|
187
|
-
) -> Any:
|
|
188
|
-
"""Log structured message information."""
|
|
189
|
-
start_entry = self._create_log_entry(context, "request_start")
|
|
190
|
-
if self.methods and context.method not in self.methods:
|
|
191
|
-
return await call_next(context)
|
|
192
|
-
|
|
193
|
-
self.logger.log(self.log_level, json.dumps(start_entry))
|
|
194
|
-
|
|
195
|
-
try:
|
|
196
|
-
result = await call_next(context)
|
|
197
|
-
|
|
198
|
-
success_entry = self._create_log_entry(
|
|
199
|
-
context,
|
|
200
|
-
"request_success",
|
|
201
|
-
result_type=type(result).__name__ if result else None,
|
|
202
|
-
)
|
|
203
|
-
self.logger.log(self.log_level, json.dumps(success_entry))
|
|
204
|
-
|
|
205
|
-
return result
|
|
206
|
-
except Exception as e:
|
|
207
|
-
error_entry = self._create_log_entry(
|
|
208
|
-
context,
|
|
209
|
-
"request_error",
|
|
210
|
-
error_type=type(e).__name__,
|
|
211
|
-
error_message=str(e),
|
|
212
|
-
)
|
|
213
|
-
self.logger.log(logging.ERROR, json.dumps(error_entry))
|
|
214
|
-
raise
|
|
244
|
+
self.max_payload_length: int | None = None
|
|
245
|
+
self.structured_logging: bool = True
|
|
@@ -15,6 +15,7 @@ from typing import (
|
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
import mcp.types as mt
|
|
18
|
+
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
18
19
|
from typing_extensions import TypeVar
|
|
19
20
|
|
|
20
21
|
from fastmcp.prompts.prompt import Prompt
|
|
@@ -154,8 +155,8 @@ class Middleware:
|
|
|
154
155
|
async def on_read_resource(
|
|
155
156
|
self,
|
|
156
157
|
context: MiddlewareContext[mt.ReadResourceRequestParams],
|
|
157
|
-
call_next: CallNext[mt.ReadResourceRequestParams,
|
|
158
|
-
) ->
|
|
158
|
+
call_next: CallNext[mt.ReadResourceRequestParams, list[ReadResourceContents]],
|
|
159
|
+
) -> list[ReadResourceContents]:
|
|
159
160
|
return await call_next(context)
|
|
160
161
|
|
|
161
162
|
async def on_get_prompt(
|
fastmcp/server/openapi.py
CHANGED
|
@@ -785,6 +785,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
785
785
|
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
|
|
786
786
|
|
|
787
787
|
# Process routes
|
|
788
|
+
num_excluded = 0
|
|
788
789
|
route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS
|
|
789
790
|
for route in http_routes:
|
|
790
791
|
# Determine route type based on mappings or default rules
|
|
@@ -823,8 +824,11 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
823
824
|
self._create_openapi_template(route, component_name, tags=route_tags)
|
|
824
825
|
elif route_type == MCPType.EXCLUDE:
|
|
825
826
|
logger.info(f"Excluding route: {route.method} {route.path}")
|
|
827
|
+
num_excluded += 1
|
|
826
828
|
|
|
827
|
-
logger.info(
|
|
829
|
+
logger.info(
|
|
830
|
+
f"Created FastMCP OpenAPI server with {len(http_routes) - num_excluded} routes"
|
|
831
|
+
)
|
|
828
832
|
|
|
829
833
|
def _generate_default_name(
|
|
830
834
|
self, route: openapi.HTTPRoute, mcp_names_map: dict[str, str] | None = None
|