fastmcp 2.11.2__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/logging.py +25 -1
- fastmcp/client/oauth_callback.py +91 -91
- fastmcp/client/sampling.py +12 -4
- fastmcp/client/transports.py +143 -67
- 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 +1 -3
- fastmcp/experimental/server/openapi/server.py +10 -25
- fastmcp/experimental/utilities/openapi/__init__.py +2 -2
- fastmcp/experimental/utilities/openapi/formatters.py +34 -0
- fastmcp/experimental/utilities/openapi/models.py +5 -2
- fastmcp/experimental/utilities/openapi/parser.py +252 -70
- fastmcp/experimental/utilities/openapi/schemas.py +135 -106
- fastmcp/mcp_config.py +40 -20
- fastmcp/prompts/prompt_manager.py +4 -2
- fastmcp/resources/resource_manager.py +16 -6
- fastmcp/server/auth/__init__.py +11 -1
- fastmcp/server/auth/auth.py +19 -2
- 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 +27 -16
- fastmcp/server/auth/providers/workos.py +256 -2
- fastmcp/server/auth/redirect_validation.py +65 -0
- fastmcp/server/auth/registry.py +1 -1
- fastmcp/server/context.py +91 -41
- fastmcp/server/dependencies.py +32 -2
- fastmcp/server/elicitation.py +60 -1
- fastmcp/server/http.py +44 -37
- fastmcp/server/middleware/logging.py +66 -28
- fastmcp/server/proxy.py +2 -0
- fastmcp/server/sampling/handler.py +19 -0
- fastmcp/server/server.py +85 -20
- fastmcp/settings.py +18 -3
- fastmcp/tools/tool.py +23 -10
- fastmcp/tools/tool_manager.py +5 -1
- fastmcp/tools/tool_transform.py +75 -32
- fastmcp/utilities/auth.py +34 -0
- fastmcp/utilities/cli.py +148 -15
- fastmcp/utilities/components.py +21 -5
- 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/openapi.py +4 -4
- fastmcp/utilities/tests.py +7 -2
- fastmcp/utilities/types.py +15 -2
- {fastmcp-2.11.2.dist-info → fastmcp-2.12.0.dist-info}/METADATA +3 -2
- fastmcp-2.12.0.dist-info/RECORD +129 -0
- fastmcp-2.11.2.dist-info/RECORD +0 -108
- {fastmcp-2.11.2.dist-info → fastmcp-2.12.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.11.2.dist-info → fastmcp-2.12.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.11.2.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/auth/registry.py
CHANGED
|
@@ -6,7 +6,7 @@ from collections.abc import Callable
|
|
|
6
6
|
from typing import TYPE_CHECKING, TypeVar
|
|
7
7
|
|
|
8
8
|
if TYPE_CHECKING:
|
|
9
|
-
from fastmcp.server.auth
|
|
9
|
+
from fastmcp.server.auth import AuthProvider
|
|
10
10
|
|
|
11
11
|
# Type variable for auth providers
|
|
12
12
|
T = TypeVar("T", bound="AuthProvider")
|
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/dependencies.py
CHANGED
|
@@ -2,10 +2,13 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING, ParamSpec, TypeVar
|
|
4
4
|
|
|
5
|
-
from mcp.server.auth.middleware.auth_context import
|
|
6
|
-
|
|
5
|
+
from mcp.server.auth.middleware.auth_context import (
|
|
6
|
+
get_access_token as _sdk_get_access_token,
|
|
7
|
+
)
|
|
7
8
|
from starlette.requests import Request
|
|
8
9
|
|
|
10
|
+
from fastmcp.server.auth import AccessToken
|
|
11
|
+
|
|
9
12
|
if TYPE_CHECKING:
|
|
10
13
|
from fastmcp.server.context import Context
|
|
11
14
|
|
|
@@ -94,3 +97,30 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]:
|
|
|
94
97
|
return headers
|
|
95
98
|
except RuntimeError:
|
|
96
99
|
return {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- Access Token ---
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_access_token() -> AccessToken | None:
|
|
106
|
+
"""
|
|
107
|
+
Get the FastMCP access token from the current context.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The access token if an authenticated user is available, None otherwise.
|
|
111
|
+
"""
|
|
112
|
+
#
|
|
113
|
+
obj = _sdk_get_access_token()
|
|
114
|
+
if obj is None or isinstance(obj, AccessToken):
|
|
115
|
+
return obj
|
|
116
|
+
|
|
117
|
+
# If the object is not a FastMCP AccessToken, convert it to one if the fields are compatible
|
|
118
|
+
# This is a workaround for the case where the SDK returns a different type
|
|
119
|
+
# If it fails, it will raise a TypeError
|
|
120
|
+
try:
|
|
121
|
+
return AccessToken(**obj.model_dump())
|
|
122
|
+
except Exception as e:
|
|
123
|
+
raise TypeError(
|
|
124
|
+
f"Expected fastmcp.server.auth.auth.AccessToken, got {type(obj).__name__}. "
|
|
125
|
+
"Ensure the SDK is using the correct AccessToken type."
|
|
126
|
+
) from e
|
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
|
@@ -23,7 +23,7 @@ from starlette.responses import Response
|
|
|
23
23
|
from starlette.routing import BaseRoute, Mount, Route
|
|
24
24
|
from starlette.types import Lifespan, Receive, Scope, Send
|
|
25
25
|
|
|
26
|
-
from fastmcp.server.auth
|
|
26
|
+
from fastmcp.server.auth import AuthProvider
|
|
27
27
|
from fastmcp.utilities.logging import get_logger
|
|
28
28
|
|
|
29
29
|
if TYPE_CHECKING:
|
|
@@ -32,7 +32,39 @@ if TYPE_CHECKING:
|
|
|
32
32
|
logger = get_logger(__name__)
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
class StreamableHTTPASGIApp:
|
|
36
|
+
"""ASGI application wrapper for Streamable HTTP server transport."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, session_manager):
|
|
39
|
+
self.session_manager = session_manager
|
|
40
|
+
|
|
41
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
42
|
+
try:
|
|
43
|
+
await self.session_manager.handle_request(scope, receive, send)
|
|
44
|
+
except RuntimeError as e:
|
|
45
|
+
if str(e) == "Task group is not initialized. Make sure to use run().":
|
|
46
|
+
logger.error(
|
|
47
|
+
f"Original RuntimeError from mcp library: {e}", exc_info=True
|
|
48
|
+
)
|
|
49
|
+
new_error_message = (
|
|
50
|
+
"FastMCP's StreamableHTTPSessionManager task group was not initialized. "
|
|
51
|
+
"This commonly occurs when the FastMCP application's lifespan is not "
|
|
52
|
+
"passed to the parent ASGI application (e.g., FastAPI or Starlette). "
|
|
53
|
+
"Please ensure you are setting `lifespan=mcp_app.lifespan` in your "
|
|
54
|
+
"parent app's constructor, where `mcp_app` is the application instance "
|
|
55
|
+
"returned by `fastmcp_instance.http_app()`. \\n"
|
|
56
|
+
"For more details, see the FastMCP ASGI integration documentation: "
|
|
57
|
+
"https://gofastmcp.com/deployment/asgi"
|
|
58
|
+
)
|
|
59
|
+
# Raise a new RuntimeError that includes the original error's message
|
|
60
|
+
# for full context, but leads with the more helpful guidance.
|
|
61
|
+
raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e
|
|
62
|
+
else:
|
|
63
|
+
# Re-raise other RuntimeErrors if they don't match the specific message
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_current_http_request: ContextVar[Request | None] = ContextVar( # type: ignore[assignment]
|
|
36
68
|
"http_request",
|
|
37
69
|
default=None,
|
|
38
70
|
)
|
|
@@ -40,7 +72,7 @@ _current_http_request: ContextVar[Request | None] = ContextVar(
|
|
|
40
72
|
|
|
41
73
|
class StarletteWithLifespan(Starlette):
|
|
42
74
|
@property
|
|
43
|
-
def lifespan(self) -> Lifespan:
|
|
75
|
+
def lifespan(self) -> Lifespan[Starlette]:
|
|
44
76
|
return self.router.lifespan_context
|
|
45
77
|
|
|
46
78
|
|
|
@@ -197,7 +229,7 @@ def create_sse_app(
|
|
|
197
229
|
# Add custom routes with lowest precedence
|
|
198
230
|
if routes:
|
|
199
231
|
server_routes.extend(routes)
|
|
200
|
-
server_routes.extend(server.
|
|
232
|
+
server_routes.extend(server._get_additional_http_routes())
|
|
201
233
|
|
|
202
234
|
# Add middleware
|
|
203
235
|
if middleware:
|
|
@@ -254,33 +286,8 @@ def create_streamable_http_app(
|
|
|
254
286
|
stateless=stateless_http,
|
|
255
287
|
)
|
|
256
288
|
|
|
257
|
-
# Create the ASGI
|
|
258
|
-
|
|
259
|
-
scope: Scope, receive: Receive, send: Send
|
|
260
|
-
) -> None:
|
|
261
|
-
try:
|
|
262
|
-
await session_manager.handle_request(scope, receive, send)
|
|
263
|
-
except RuntimeError as e:
|
|
264
|
-
if str(e) == "Task group is not initialized. Make sure to use run().":
|
|
265
|
-
logger.error(
|
|
266
|
-
f"Original RuntimeError from mcp library: {e}", exc_info=True
|
|
267
|
-
)
|
|
268
|
-
new_error_message = (
|
|
269
|
-
"FastMCP's StreamableHTTPSessionManager task group was not initialized. "
|
|
270
|
-
"This commonly occurs when the FastMCP application's lifespan is not "
|
|
271
|
-
"passed to the parent ASGI application (e.g., FastAPI or Starlette). "
|
|
272
|
-
"Please ensure you are setting `lifespan=mcp_app.lifespan` in your "
|
|
273
|
-
"parent app's constructor, where `mcp_app` is the application instance "
|
|
274
|
-
"returned by `fastmcp_instance.http_app()`. \\n"
|
|
275
|
-
"For more details, see the FastMCP ASGI integration documentation: "
|
|
276
|
-
"https://gofastmcp.com/deployment/asgi"
|
|
277
|
-
)
|
|
278
|
-
# Raise a new RuntimeError that includes the original error's message
|
|
279
|
-
# for full context, but leads with the more helpful guidance.
|
|
280
|
-
raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e
|
|
281
|
-
else:
|
|
282
|
-
# Re-raise other RuntimeErrors if they don't match the specific message
|
|
283
|
-
raise
|
|
289
|
+
# Create the ASGI app wrapper
|
|
290
|
+
streamable_http_app = StreamableHTTPASGIApp(session_manager)
|
|
284
291
|
|
|
285
292
|
# Add StreamableHTTP routes with or without auth
|
|
286
293
|
if auth:
|
|
@@ -305,26 +312,26 @@ def create_streamable_http_app(
|
|
|
305
312
|
|
|
306
313
|
# Auth is enabled, wrap endpoint with RequireAuthMiddleware
|
|
307
314
|
server_routes.append(
|
|
308
|
-
|
|
315
|
+
Route(
|
|
309
316
|
streamable_http_path,
|
|
310
|
-
|
|
311
|
-
|
|
317
|
+
endpoint=RequireAuthMiddleware(
|
|
318
|
+
streamable_http_app, required_scopes, resource_metadata_url
|
|
312
319
|
),
|
|
313
320
|
)
|
|
314
321
|
)
|
|
315
322
|
else:
|
|
316
323
|
# No auth required
|
|
317
324
|
server_routes.append(
|
|
318
|
-
|
|
325
|
+
Route(
|
|
319
326
|
streamable_http_path,
|
|
320
|
-
|
|
327
|
+
endpoint=streamable_http_app,
|
|
321
328
|
)
|
|
322
329
|
)
|
|
323
330
|
|
|
324
331
|
# Add custom routes with lowest precedence
|
|
325
332
|
if routes:
|
|
326
333
|
server_routes.extend(routes)
|
|
327
|
-
server_routes.extend(server.
|
|
334
|
+
server_routes.extend(server._get_additional_http_routes())
|
|
328
335
|
|
|
329
336
|
# Add middleware
|
|
330
337
|
if middleware:
|