fastmcp 2.11.3__py3-none-any.whl → 2.12.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/__init__.py +5 -4
- fastmcp/cli/claude.py +22 -18
- fastmcp/cli/cli.py +472 -136
- fastmcp/cli/install/claude_code.py +37 -40
- fastmcp/cli/install/claude_desktop.py +37 -42
- fastmcp/cli/install/cursor.py +148 -38
- fastmcp/cli/install/mcp_json.py +38 -43
- fastmcp/cli/install/shared.py +64 -7
- fastmcp/cli/run.py +122 -215
- fastmcp/client/auth/oauth.py +69 -13
- fastmcp/client/client.py +46 -9
- fastmcp/client/oauth_callback.py +91 -91
- fastmcp/client/sampling.py +12 -4
- fastmcp/client/transports.py +139 -64
- fastmcp/experimental/sampling/__init__.py +0 -0
- fastmcp/experimental/sampling/handlers/__init__.py +3 -0
- fastmcp/experimental/sampling/handlers/base.py +21 -0
- fastmcp/experimental/sampling/handlers/openai.py +163 -0
- fastmcp/experimental/server/openapi/routing.py +0 -2
- fastmcp/experimental/server/openapi/server.py +0 -2
- fastmcp/experimental/utilities/openapi/parser.py +5 -1
- fastmcp/mcp_config.py +40 -20
- fastmcp/prompts/prompt_manager.py +2 -0
- fastmcp/resources/resource_manager.py +4 -0
- fastmcp/server/auth/__init__.py +2 -0
- fastmcp/server/auth/auth.py +2 -1
- fastmcp/server/auth/oauth_proxy.py +1047 -0
- fastmcp/server/auth/providers/azure.py +270 -0
- fastmcp/server/auth/providers/github.py +287 -0
- fastmcp/server/auth/providers/google.py +305 -0
- fastmcp/server/auth/providers/jwt.py +24 -12
- fastmcp/server/auth/providers/workos.py +256 -2
- fastmcp/server/auth/redirect_validation.py +65 -0
- fastmcp/server/context.py +91 -41
- fastmcp/server/elicitation.py +60 -1
- fastmcp/server/http.py +3 -3
- fastmcp/server/middleware/logging.py +66 -28
- fastmcp/server/proxy.py +2 -0
- fastmcp/server/sampling/handler.py +19 -0
- fastmcp/server/server.py +76 -15
- fastmcp/settings.py +16 -1
- fastmcp/tools/tool.py +22 -9
- fastmcp/tools/tool_manager.py +2 -0
- fastmcp/tools/tool_transform.py +39 -10
- fastmcp/utilities/auth.py +34 -0
- fastmcp/utilities/cli.py +148 -15
- fastmcp/utilities/components.py +2 -1
- fastmcp/utilities/inspect.py +166 -37
- fastmcp/utilities/json_schema_type.py +4 -2
- fastmcp/utilities/logging.py +4 -1
- fastmcp/utilities/mcp_config.py +47 -18
- fastmcp/utilities/mcp_server_config/__init__.py +25 -0
- fastmcp/utilities/mcp_server_config/v1/__init__.py +0 -0
- fastmcp/utilities/mcp_server_config/v1/environments/__init__.py +6 -0
- fastmcp/utilities/mcp_server_config/v1/environments/base.py +30 -0
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +306 -0
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +446 -0
- fastmcp/utilities/mcp_server_config/v1/schema.json +361 -0
- fastmcp/utilities/mcp_server_config/v1/sources/__init__.py +0 -0
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +30 -0
- fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py +216 -0
- fastmcp/utilities/tests.py +7 -2
- fastmcp/utilities/types.py +15 -2
- {fastmcp-2.11.3.dist-info → fastmcp-2.12.0.dist-info}/METADATA +2 -1
- fastmcp-2.12.0.dist-info/RECORD +129 -0
- fastmcp-2.11.3.dist-info/RECORD +0 -108
- {fastmcp-2.11.3.dist-info → fastmcp-2.12.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.11.3.dist-info → fastmcp-2.12.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.11.3.dist-info → fastmcp-2.12.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Utilities for validating client redirect URIs in OAuth flows."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
|
|
5
|
+
from pydantic import AnyUrl
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def matches_allowed_pattern(uri: str, pattern: str) -> bool:
|
|
9
|
+
"""Check if a URI matches an allowed pattern with wildcard support.
|
|
10
|
+
|
|
11
|
+
Patterns support * wildcard matching:
|
|
12
|
+
- http://localhost:* matches any localhost port
|
|
13
|
+
- http://127.0.0.1:* matches any 127.0.0.1 port
|
|
14
|
+
- https://*.example.com/* matches any subdomain of example.com
|
|
15
|
+
- https://app.example.com/auth/* matches any path under /auth/
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
uri: The redirect URI to validate
|
|
19
|
+
pattern: The allowed pattern (may contain wildcards)
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
True if the URI matches the pattern
|
|
23
|
+
"""
|
|
24
|
+
# Use fnmatch for wildcard matching
|
|
25
|
+
return fnmatch.fnmatch(uri, pattern)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_redirect_uri(
|
|
29
|
+
redirect_uri: str | AnyUrl | None,
|
|
30
|
+
allowed_patterns: list[str] | None,
|
|
31
|
+
) -> bool:
|
|
32
|
+
"""Validate a redirect URI against allowed patterns.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
redirect_uri: The redirect URI to validate
|
|
36
|
+
allowed_patterns: List of allowed patterns. If None, all URIs are allowed (for DCR compatibility).
|
|
37
|
+
If empty list, no URIs are allowed.
|
|
38
|
+
To restrict to localhost only, explicitly pass DEFAULT_LOCALHOST_PATTERNS.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
True if the redirect URI is allowed
|
|
42
|
+
"""
|
|
43
|
+
if redirect_uri is None:
|
|
44
|
+
return True # None is allowed (will use client's default)
|
|
45
|
+
|
|
46
|
+
uri_str = str(redirect_uri)
|
|
47
|
+
|
|
48
|
+
# If no patterns specified, allow all for DCR compatibility
|
|
49
|
+
# (clients need to dynamically register with their own redirect URIs)
|
|
50
|
+
if allowed_patterns is None:
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
# Check if URI matches any allowed pattern
|
|
54
|
+
for pattern in allowed_patterns:
|
|
55
|
+
if matches_allowed_pattern(uri_str, pattern):
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Default patterns for localhost-only validation
|
|
62
|
+
DEFAULT_LOCALHOST_PATTERNS = [
|
|
63
|
+
"http://localhost:*",
|
|
64
|
+
"http://127.0.0.1:*",
|
|
65
|
+
]
|
fastmcp/server/context.py
CHANGED
|
@@ -2,7 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import copy
|
|
5
|
+
import inspect
|
|
5
6
|
import warnings
|
|
7
|
+
import weakref
|
|
6
8
|
from collections.abc import Generator, Mapping
|
|
7
9
|
from contextlib import contextmanager
|
|
8
10
|
from contextvars import ContextVar, Token
|
|
@@ -15,15 +17,18 @@ from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
|
15
17
|
from mcp.server.lowlevel.server import request_ctx
|
|
16
18
|
from mcp.shared.context import RequestContext
|
|
17
19
|
from mcp.types import (
|
|
20
|
+
ClientCapabilities,
|
|
18
21
|
ContentBlock,
|
|
19
22
|
CreateMessageResult,
|
|
20
23
|
IncludeContext,
|
|
21
24
|
ModelHint,
|
|
22
25
|
ModelPreferences,
|
|
23
26
|
Root,
|
|
27
|
+
SamplingCapability,
|
|
24
28
|
SamplingMessage,
|
|
25
29
|
TextContent,
|
|
26
30
|
)
|
|
31
|
+
from mcp.types import CreateMessageRequestParams as SamplingParams
|
|
27
32
|
from pydantic.networks import AnyUrl
|
|
28
33
|
from starlette.requests import Request
|
|
29
34
|
|
|
@@ -43,7 +48,7 @@ from fastmcp.utilities.types import get_cached_typeadapter
|
|
|
43
48
|
logger = get_logger(__name__)
|
|
44
49
|
|
|
45
50
|
T = TypeVar("T")
|
|
46
|
-
_current_context: ContextVar[Context | None] = ContextVar("context", default=None)
|
|
51
|
+
_current_context: ContextVar[Context | None] = ContextVar("context", default=None) # type: ignore[assignment]
|
|
47
52
|
_flush_lock = asyncio.Lock()
|
|
48
53
|
|
|
49
54
|
|
|
@@ -115,11 +120,19 @@ class Context:
|
|
|
115
120
|
"""
|
|
116
121
|
|
|
117
122
|
def __init__(self, fastmcp: FastMCP):
|
|
118
|
-
self.
|
|
123
|
+
self._fastmcp: weakref.ref[FastMCP] = weakref.ref(fastmcp)
|
|
119
124
|
self._tokens: list[Token] = []
|
|
120
125
|
self._notification_queue: set[str] = set() # Dedupe notifications
|
|
121
126
|
self._state: dict[str, Any] = {}
|
|
122
127
|
|
|
128
|
+
@property
|
|
129
|
+
def fastmcp(self) -> FastMCP:
|
|
130
|
+
"""Get the FastMCP instance."""
|
|
131
|
+
fastmcp = self._fastmcp()
|
|
132
|
+
if fastmcp is None:
|
|
133
|
+
raise RuntimeError("FastMCP instance is no longer available")
|
|
134
|
+
return fastmcp
|
|
135
|
+
|
|
123
136
|
async def __aenter__(self) -> Context:
|
|
124
137
|
"""Enter the context manager and set this context as the current context."""
|
|
125
138
|
parent_context = _current_context.get(None)
|
|
@@ -188,7 +201,8 @@ class Context:
|
|
|
188
201
|
Returns:
|
|
189
202
|
The resource content as either text or bytes
|
|
190
203
|
"""
|
|
191
|
-
|
|
204
|
+
if self.fastmcp is None:
|
|
205
|
+
raise ValueError("Context is not available outside of a request")
|
|
192
206
|
return await self.fastmcp._mcp_read_resource(uri)
|
|
193
207
|
|
|
194
208
|
async def log(
|
|
@@ -376,13 +390,50 @@ class Context:
|
|
|
376
390
|
for m in messages
|
|
377
391
|
]
|
|
378
392
|
|
|
393
|
+
should_fallback = (
|
|
394
|
+
self.fastmcp.sampling_handler_behavior == "fallback"
|
|
395
|
+
and not self.session.check_client_capability(
|
|
396
|
+
capability=ClientCapabilities(sampling=SamplingCapability())
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if self.fastmcp.sampling_handler_behavior == "always" or should_fallback:
|
|
401
|
+
if self.fastmcp.sampling_handler is None:
|
|
402
|
+
raise ValueError("Client does not support sampling")
|
|
403
|
+
|
|
404
|
+
create_message_result = self.fastmcp.sampling_handler(
|
|
405
|
+
sampling_messages,
|
|
406
|
+
SamplingParams(
|
|
407
|
+
systemPrompt=system_prompt,
|
|
408
|
+
messages=sampling_messages,
|
|
409
|
+
temperature=temperature,
|
|
410
|
+
maxTokens=max_tokens,
|
|
411
|
+
modelPreferences=_parse_model_preferences(model_preferences),
|
|
412
|
+
),
|
|
413
|
+
self.request_context,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
if inspect.isawaitable(create_message_result):
|
|
417
|
+
create_message_result = await create_message_result
|
|
418
|
+
|
|
419
|
+
if isinstance(create_message_result, str):
|
|
420
|
+
return TextContent(text=create_message_result, type="text")
|
|
421
|
+
|
|
422
|
+
if isinstance(create_message_result, CreateMessageResult):
|
|
423
|
+
return create_message_result.content
|
|
424
|
+
|
|
425
|
+
else:
|
|
426
|
+
raise ValueError(
|
|
427
|
+
f"Unexpected sampling handler result: {create_message_result}"
|
|
428
|
+
)
|
|
429
|
+
|
|
379
430
|
result: CreateMessageResult = await self.session.create_message(
|
|
380
431
|
messages=sampling_messages,
|
|
381
432
|
system_prompt=system_prompt,
|
|
382
433
|
include_context=include_context,
|
|
383
434
|
temperature=temperature,
|
|
384
435
|
max_tokens=max_tokens,
|
|
385
|
-
model_preferences=
|
|
436
|
+
model_preferences=_parse_model_preferences(model_preferences),
|
|
386
437
|
related_request_id=self.request_id,
|
|
387
438
|
)
|
|
388
439
|
|
|
@@ -497,7 +548,7 @@ class Context:
|
|
|
497
548
|
if isinstance(validated_data, ScalarElicitationType):
|
|
498
549
|
return AcceptedElicitation[T](data=validated_data.value)
|
|
499
550
|
else:
|
|
500
|
-
return AcceptedElicitation[T](data=validated_data)
|
|
551
|
+
return AcceptedElicitation[T](data=cast(T, validated_data))
|
|
501
552
|
elif result.content:
|
|
502
553
|
raise ValueError(
|
|
503
554
|
"Elicitation expected an empty response, but received: "
|
|
@@ -582,44 +633,43 @@ class Context:
|
|
|
582
633
|
# Don't let notification failures break the request
|
|
583
634
|
pass
|
|
584
635
|
|
|
585
|
-
def _parse_model_preferences(
|
|
586
|
-
self, model_preferences: ModelPreferences | str | list[str] | None
|
|
587
|
-
) -> ModelPreferences | None:
|
|
588
|
-
"""
|
|
589
|
-
Validates and converts user input for model_preferences into a ModelPreferences object.
|
|
590
636
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
- list[str] (multiple model hints)
|
|
597
|
-
- None (no preferences)
|
|
637
|
+
def _parse_model_preferences(
|
|
638
|
+
model_preferences: ModelPreferences | str | list[str] | None,
|
|
639
|
+
) -> ModelPreferences | None:
|
|
640
|
+
"""
|
|
641
|
+
Validates and converts user input for model_preferences into a ModelPreferences object.
|
|
598
642
|
|
|
599
|
-
|
|
600
|
-
|
|
643
|
+
Args:
|
|
644
|
+
model_preferences (ModelPreferences | str | list[str] | None):
|
|
645
|
+
The model preferences to use. Accepts:
|
|
646
|
+
- ModelPreferences (returns as-is)
|
|
647
|
+
- str (single model hint)
|
|
648
|
+
- list[str] (multiple model hints)
|
|
649
|
+
- None (no preferences)
|
|
601
650
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
)
|
|
619
|
-
return ModelPreferences(
|
|
620
|
-
hints=[ModelHint(name=h) for h in model_preferences]
|
|
621
|
-
)
|
|
622
|
-
else:
|
|
651
|
+
Returns:
|
|
652
|
+
ModelPreferences | None: The parsed ModelPreferences object, or None if not provided.
|
|
653
|
+
|
|
654
|
+
Raises:
|
|
655
|
+
ValueError: If the input is not a supported type or contains invalid values.
|
|
656
|
+
"""
|
|
657
|
+
if model_preferences is None:
|
|
658
|
+
return None
|
|
659
|
+
elif isinstance(model_preferences, ModelPreferences):
|
|
660
|
+
return model_preferences
|
|
661
|
+
elif isinstance(model_preferences, str):
|
|
662
|
+
# Single model hint
|
|
663
|
+
return ModelPreferences(hints=[ModelHint(name=model_preferences)])
|
|
664
|
+
elif isinstance(model_preferences, list):
|
|
665
|
+
# List of model hints (strings)
|
|
666
|
+
if not all(isinstance(h, str) for h in model_preferences):
|
|
623
667
|
raise ValueError(
|
|
624
|
-
"
|
|
668
|
+
"All elements of model_preferences list must be"
|
|
669
|
+
" strings (model name hints)."
|
|
625
670
|
)
|
|
671
|
+
return ModelPreferences(hints=[ModelHint(name=h) for h in model_preferences])
|
|
672
|
+
else:
|
|
673
|
+
raise ValueError(
|
|
674
|
+
"model_preferences must be one of: ModelPreferences, str, list[str], or None."
|
|
675
|
+
)
|
fastmcp/server/elicitation.py
CHANGED
|
@@ -8,6 +8,8 @@ from mcp.server.elicitation import (
|
|
|
8
8
|
DeclinedElicitation,
|
|
9
9
|
)
|
|
10
10
|
from pydantic import BaseModel
|
|
11
|
+
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
|
|
12
|
+
from pydantic_core import core_schema
|
|
11
13
|
|
|
12
14
|
from fastmcp.utilities.json_schema import compress_schema
|
|
13
15
|
from fastmcp.utilities.logging import get_logger
|
|
@@ -26,6 +28,60 @@ logger = get_logger(__name__)
|
|
|
26
28
|
T = TypeVar("T")
|
|
27
29
|
|
|
28
30
|
|
|
31
|
+
class ElicitationJsonSchema(GenerateJsonSchema):
|
|
32
|
+
"""Custom JSON schema generator for MCP elicitation that always inlines enums.
|
|
33
|
+
|
|
34
|
+
MCP elicitation requires inline enum schemas without $ref/$defs references.
|
|
35
|
+
This generator ensures enums are always generated inline for compatibility.
|
|
36
|
+
Optionally adds enumNames for better UI display when available.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def generate_inner(self, schema: core_schema.CoreSchema) -> JsonSchemaValue:
|
|
40
|
+
"""Override to prevent ref generation for enums."""
|
|
41
|
+
# For enum schemas, bypass the ref mechanism entirely
|
|
42
|
+
if schema["type"] == "enum":
|
|
43
|
+
# Directly call our custom enum_schema without going through handler
|
|
44
|
+
# This prevents the ref/defs mechanism from being invoked
|
|
45
|
+
return self.enum_schema(schema)
|
|
46
|
+
# For all other types, use the default implementation
|
|
47
|
+
return super().generate_inner(schema)
|
|
48
|
+
|
|
49
|
+
def enum_schema(self, schema: core_schema.EnumSchema) -> JsonSchemaValue:
|
|
50
|
+
"""Generate inline enum schema with optional enumNames for better UI.
|
|
51
|
+
|
|
52
|
+
If enum members have a _display_name_ attribute or custom __str__,
|
|
53
|
+
we'll include enumNames for better UI representation.
|
|
54
|
+
"""
|
|
55
|
+
# Get the base schema from parent
|
|
56
|
+
result = super().enum_schema(schema)
|
|
57
|
+
|
|
58
|
+
# Try to add enumNames if the enum has display-friendly names
|
|
59
|
+
enum_cls = schema.get("cls")
|
|
60
|
+
if enum_cls:
|
|
61
|
+
members = schema.get("members", [])
|
|
62
|
+
enum_names = []
|
|
63
|
+
has_custom_names = False
|
|
64
|
+
|
|
65
|
+
for member in members:
|
|
66
|
+
# Check if member has a custom display name attribute
|
|
67
|
+
if hasattr(member, "_display_name_"):
|
|
68
|
+
enum_names.append(member._display_name_)
|
|
69
|
+
has_custom_names = True
|
|
70
|
+
# Or use the member name with better formatting
|
|
71
|
+
else:
|
|
72
|
+
# Convert SNAKE_CASE to Title Case for display
|
|
73
|
+
display_name = member.name.replace("_", " ").title()
|
|
74
|
+
enum_names.append(display_name)
|
|
75
|
+
if display_name != member.value:
|
|
76
|
+
has_custom_names = True
|
|
77
|
+
|
|
78
|
+
# Only add enumNames if they differ from the values
|
|
79
|
+
if has_custom_names:
|
|
80
|
+
result["enumNames"] = enum_names
|
|
81
|
+
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
29
85
|
# we can't use the low-level AcceptedElicitation because it only works with BaseModels
|
|
30
86
|
class AcceptedElicitation(BaseModel, Generic[T]):
|
|
31
87
|
"""Result when user accepts the elicitation."""
|
|
@@ -46,7 +102,10 @@ def get_elicitation_schema(response_type: type[T]) -> dict[str, Any]:
|
|
|
46
102
|
response_type: The type of the response
|
|
47
103
|
"""
|
|
48
104
|
|
|
49
|
-
schema
|
|
105
|
+
# Use custom schema generator that inlines enums for MCP compatibility
|
|
106
|
+
schema = get_cached_typeadapter(response_type).json_schema(
|
|
107
|
+
schema_generator=ElicitationJsonSchema
|
|
108
|
+
)
|
|
50
109
|
schema = compress_schema(schema)
|
|
51
110
|
|
|
52
111
|
# Validate the schema to ensure it follows MCP elicitation requirements
|
fastmcp/server/http.py
CHANGED
|
@@ -64,7 +64,7 @@ class StreamableHTTPASGIApp:
|
|
|
64
64
|
raise
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
_current_http_request: ContextVar[Request | None] = ContextVar(
|
|
67
|
+
_current_http_request: ContextVar[Request | None] = ContextVar( # type: ignore[assignment]
|
|
68
68
|
"http_request",
|
|
69
69
|
default=None,
|
|
70
70
|
)
|
|
@@ -229,7 +229,7 @@ def create_sse_app(
|
|
|
229
229
|
# Add custom routes with lowest precedence
|
|
230
230
|
if routes:
|
|
231
231
|
server_routes.extend(routes)
|
|
232
|
-
server_routes.extend(server.
|
|
232
|
+
server_routes.extend(server._get_additional_http_routes())
|
|
233
233
|
|
|
234
234
|
# Add middleware
|
|
235
235
|
if middleware:
|
|
@@ -331,7 +331,7 @@ def create_streamable_http_app(
|
|
|
331
331
|
# Add custom routes with lowest precedence
|
|
332
332
|
if routes:
|
|
333
333
|
server_routes.extend(routes)
|
|
334
|
-
server_routes.extend(server.
|
|
334
|
+
server_routes.extend(server._get_additional_http_routes())
|
|
335
335
|
|
|
336
336
|
# Add middleware
|
|
337
337
|
if middleware:
|
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from logging import Logger
|
|
5
7
|
from typing import Any
|
|
6
8
|
|
|
9
|
+
import pydantic_core
|
|
10
|
+
|
|
7
11
|
from .middleware import CallNext, Middleware, MiddlewareContext
|
|
8
12
|
|
|
9
13
|
|
|
14
|
+
def default_serializer(data: Any) -> str:
|
|
15
|
+
"""The default serializer for Payloads in the logging middleware."""
|
|
16
|
+
return pydantic_core.to_json(data, fallback=str).decode()
|
|
17
|
+
|
|
18
|
+
|
|
10
19
|
class LoggingMiddleware(Middleware):
|
|
11
20
|
"""Middleware that provides comprehensive request and response logging.
|
|
12
21
|
|
|
@@ -33,6 +42,7 @@ class LoggingMiddleware(Middleware):
|
|
|
33
42
|
include_payloads: bool = False,
|
|
34
43
|
max_payload_length: int = 1000,
|
|
35
44
|
methods: list[str] | None = None,
|
|
45
|
+
payload_serializer: Callable[[Any], str] | None = None,
|
|
36
46
|
):
|
|
37
47
|
"""Initialize logging middleware.
|
|
38
48
|
|
|
@@ -43,13 +53,14 @@ class LoggingMiddleware(Middleware):
|
|
|
43
53
|
max_payload_length: Maximum length of payload to log (prevents huge logs)
|
|
44
54
|
methods: List of methods to log. If None, logs all methods.
|
|
45
55
|
"""
|
|
46
|
-
self.logger = logger or logging.getLogger("fastmcp.requests")
|
|
47
|
-
self.log_level = log_level
|
|
48
|
-
self.include_payloads = include_payloads
|
|
49
|
-
self.max_payload_length = max_payload_length
|
|
50
|
-
self.methods = methods
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
self.logger: Logger = logger or logging.getLogger("fastmcp.requests")
|
|
57
|
+
self.log_level: int = log_level
|
|
58
|
+
self.include_payloads: bool = include_payloads
|
|
59
|
+
self.max_payload_length: int = max_payload_length
|
|
60
|
+
self.methods: list[str] | None = methods
|
|
61
|
+
self.payload_serializer: Callable[[Any], str] | None = payload_serializer
|
|
62
|
+
|
|
63
|
+
def _format_message(self, context: MiddlewareContext[Any]) -> str:
|
|
53
64
|
"""Format a message for logging."""
|
|
54
65
|
parts = [
|
|
55
66
|
f"source={context.source}",
|
|
@@ -57,18 +68,29 @@ class LoggingMiddleware(Middleware):
|
|
|
57
68
|
f"method={context.method or 'unknown'}",
|
|
58
69
|
]
|
|
59
70
|
|
|
60
|
-
if self.include_payloads
|
|
61
|
-
|
|
62
|
-
payload = json.dumps(context.message.__dict__, default=str)
|
|
63
|
-
if len(payload) > self.max_payload_length:
|
|
64
|
-
payload = payload[: self.max_payload_length] + "..."
|
|
65
|
-
parts.append(f"payload={payload}")
|
|
66
|
-
except (TypeError, ValueError):
|
|
67
|
-
parts.append("payload=<non-serializable>")
|
|
71
|
+
if self.include_payloads:
|
|
72
|
+
payload: str
|
|
68
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
|
+
|
|
88
|
+
parts.append(f"payload={payload}")
|
|
69
89
|
return " ".join(parts)
|
|
70
90
|
|
|
71
|
-
async def on_message(
|
|
91
|
+
async def on_message(
|
|
92
|
+
self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]
|
|
93
|
+
) -> Any:
|
|
72
94
|
"""Log all messages."""
|
|
73
95
|
message_info = self._format_message(context)
|
|
74
96
|
if self.methods and context.method not in self.methods:
|
|
@@ -111,6 +133,7 @@ class StructuredLoggingMiddleware(Middleware):
|
|
|
111
133
|
log_level: int = logging.INFO,
|
|
112
134
|
include_payloads: bool = False,
|
|
113
135
|
methods: list[str] | None = None,
|
|
136
|
+
payload_serializer: Callable[[Any], str] | None = None,
|
|
114
137
|
):
|
|
115
138
|
"""Initialize structured logging middleware.
|
|
116
139
|
|
|
@@ -119,15 +142,18 @@ class StructuredLoggingMiddleware(Middleware):
|
|
|
119
142
|
log_level: Log level for messages (default: INFO)
|
|
120
143
|
include_payloads: Whether to include message payloads in logs
|
|
121
144
|
methods: List of methods to log. If None, logs all methods.
|
|
145
|
+
serializer: Callable that converts objects to a JSON string for the
|
|
146
|
+
payload. If not provided, uses FastMCP's default tool serializer.
|
|
122
147
|
"""
|
|
123
|
-
self.logger = logger or logging.getLogger("fastmcp.structured")
|
|
124
|
-
self.log_level = log_level
|
|
125
|
-
self.include_payloads = include_payloads
|
|
126
|
-
self.methods = methods
|
|
148
|
+
self.logger: Logger = logger or logging.getLogger("fastmcp.structured")
|
|
149
|
+
self.log_level: int = log_level
|
|
150
|
+
self.include_payloads: bool = include_payloads
|
|
151
|
+
self.methods: list[str] | None = methods
|
|
152
|
+
self.payload_serializer: Callable[[Any], str] | None = payload_serializer
|
|
127
153
|
|
|
128
154
|
def _create_log_entry(
|
|
129
|
-
self, context: MiddlewareContext, event: str, **extra_fields
|
|
130
|
-
) -> dict:
|
|
155
|
+
self, context: MiddlewareContext[Any], event: str, **extra_fields: Any
|
|
156
|
+
) -> dict[str, Any]:
|
|
131
157
|
"""Create a structured log entry."""
|
|
132
158
|
entry = {
|
|
133
159
|
"event": event,
|
|
@@ -138,15 +164,27 @@ class StructuredLoggingMiddleware(Middleware):
|
|
|
138
164
|
**extra_fields,
|
|
139
165
|
}
|
|
140
166
|
|
|
141
|
-
if self.include_payloads
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
146
182
|
|
|
147
183
|
return entry
|
|
148
184
|
|
|
149
|
-
async def on_message(
|
|
185
|
+
async def on_message(
|
|
186
|
+
self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]
|
|
187
|
+
) -> Any:
|
|
150
188
|
"""Log structured message information."""
|
|
151
189
|
start_entry = self._create_log_entry(context, "request_start")
|
|
152
190
|
if self.methods and context.method not in self.methods:
|
fastmcp/server/proxy.py
CHANGED
|
@@ -546,6 +546,8 @@ class ProxyClient(Client[ClientTransportT]):
|
|
|
546
546
|
| str,
|
|
547
547
|
**kwargs,
|
|
548
548
|
):
|
|
549
|
+
if "name" not in kwargs:
|
|
550
|
+
kwargs["name"] = self.generate_name()
|
|
549
551
|
if "roots" not in kwargs:
|
|
550
552
|
kwargs["roots"] = default_proxy_roots_handler
|
|
551
553
|
if "sampling_handler" not in kwargs:
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from typing import TypeAlias
|
|
3
|
+
|
|
4
|
+
from mcp import CreateMessageResult
|
|
5
|
+
from mcp.server.session import ServerSession
|
|
6
|
+
from mcp.shared.context import LifespanContextT, RequestContext
|
|
7
|
+
from mcp.types import CreateMessageRequestParams as SamplingParams
|
|
8
|
+
from mcp.types import (
|
|
9
|
+
SamplingMessage,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
ServerSamplingHandler: TypeAlias = Callable[
|
|
13
|
+
[
|
|
14
|
+
list[SamplingMessage],
|
|
15
|
+
SamplingParams,
|
|
16
|
+
RequestContext[ServerSession, LifespanContextT],
|
|
17
|
+
],
|
|
18
|
+
str | CreateMessageResult | Awaitable[str | CreateMessageResult],
|
|
19
|
+
]
|