fastmcp 2.10.6__py3-none-any.whl → 2.11.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 +128 -33
- fastmcp/cli/install/claude_code.py +42 -1
- fastmcp/cli/install/claude_desktop.py +42 -1
- fastmcp/cli/install/cursor.py +42 -1
- fastmcp/cli/install/mcp_json.py +41 -0
- fastmcp/cli/run.py +127 -1
- fastmcp/client/__init__.py +2 -0
- fastmcp/client/auth/oauth.py +68 -99
- fastmcp/client/oauth_callback.py +18 -0
- fastmcp/client/transports.py +69 -15
- fastmcp/contrib/component_manager/example.py +2 -2
- fastmcp/experimental/server/openapi/README.md +266 -0
- fastmcp/experimental/server/openapi/__init__.py +38 -0
- fastmcp/experimental/server/openapi/components.py +348 -0
- fastmcp/experimental/server/openapi/routing.py +132 -0
- fastmcp/experimental/server/openapi/server.py +466 -0
- fastmcp/experimental/utilities/openapi/README.md +239 -0
- fastmcp/experimental/utilities/openapi/__init__.py +68 -0
- fastmcp/experimental/utilities/openapi/director.py +208 -0
- fastmcp/experimental/utilities/openapi/formatters.py +355 -0
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +340 -0
- fastmcp/experimental/utilities/openapi/models.py +85 -0
- fastmcp/experimental/utilities/openapi/parser.py +618 -0
- fastmcp/experimental/utilities/openapi/schemas.py +538 -0
- fastmcp/mcp_config.py +125 -88
- fastmcp/prompts/prompt.py +11 -1
- fastmcp/resources/resource.py +21 -1
- fastmcp/resources/template.py +20 -1
- fastmcp/server/auth/__init__.py +17 -2
- fastmcp/server/auth/auth.py +144 -7
- fastmcp/server/auth/providers/bearer.py +25 -473
- fastmcp/server/auth/providers/in_memory.py +4 -2
- fastmcp/server/auth/providers/jwt.py +538 -0
- fastmcp/server/auth/providers/workos.py +170 -0
- fastmcp/server/auth/registry.py +52 -0
- fastmcp/server/context.py +107 -26
- fastmcp/server/dependencies.py +9 -2
- fastmcp/server/http.py +62 -30
- fastmcp/server/middleware/middleware.py +3 -23
- fastmcp/server/openapi.py +1 -1
- fastmcp/server/proxy.py +50 -11
- fastmcp/server/server.py +168 -59
- fastmcp/settings.py +73 -6
- fastmcp/tools/tool.py +36 -3
- fastmcp/tools/tool_manager.py +38 -2
- fastmcp/tools/tool_transform.py +112 -3
- fastmcp/utilities/components.py +35 -2
- fastmcp/utilities/json_schema.py +136 -98
- fastmcp/utilities/json_schema_type.py +1 -3
- fastmcp/utilities/mcp_config.py +28 -0
- fastmcp/utilities/openapi.py +240 -50
- fastmcp/utilities/tests.py +54 -6
- fastmcp/utilities/types.py +89 -11
- {fastmcp-2.10.6.dist-info → fastmcp-2.11.0.dist-info}/METADATA +4 -3
- fastmcp-2.11.0.dist-info/RECORD +108 -0
- fastmcp/server/auth/providers/bearer_env.py +0 -63
- fastmcp/utilities/cache.py +0 -26
- fastmcp-2.10.6.dist-info/RECORD +0 -93
- {fastmcp-2.10.6.dist-info → fastmcp-2.11.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.10.6.dist-info → fastmcp-2.11.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.10.6.dist-info → fastmcp-2.11.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from mcp.server.auth.provider import (
|
|
5
|
+
AccessToken,
|
|
6
|
+
)
|
|
7
|
+
from pydantic import AnyHttpUrl
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
from starlette.responses import JSONResponse
|
|
10
|
+
from starlette.routing import BaseRoute, Route
|
|
11
|
+
|
|
12
|
+
from fastmcp.server.auth.auth import AuthProvider, TokenVerifier
|
|
13
|
+
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
|
14
|
+
from fastmcp.server.auth.registry import register_provider
|
|
15
|
+
from fastmcp.utilities.logging import get_logger
|
|
16
|
+
from fastmcp.utilities.types import NotSet, NotSetT
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthKitProviderSettings(BaseSettings):
|
|
22
|
+
model_config = SettingsConfigDict(
|
|
23
|
+
env_prefix="FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_",
|
|
24
|
+
env_file=".env",
|
|
25
|
+
extra="ignore",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
authkit_domain: AnyHttpUrl
|
|
29
|
+
base_url: AnyHttpUrl
|
|
30
|
+
required_scopes: list[str] | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@register_provider("AUTHKIT")
|
|
34
|
+
class AuthKitProvider(AuthProvider):
|
|
35
|
+
"""WorkOS AuthKit metadata provider for DCR (Dynamic Client Registration).
|
|
36
|
+
|
|
37
|
+
This provider implements WorkOS AuthKit integration using metadata forwarding
|
|
38
|
+
instead of OAuth proxying. This is the recommended approach for WorkOS DCR
|
|
39
|
+
as it allows WorkOS to handle the OAuth flow directly while FastMCP acts
|
|
40
|
+
as a resource server.
|
|
41
|
+
|
|
42
|
+
IMPORTANT SETUP REQUIREMENTS:
|
|
43
|
+
|
|
44
|
+
1. Enable Dynamic Client Registration in WorkOS Dashboard:
|
|
45
|
+
- Go to Applications → Configuration
|
|
46
|
+
- Toggle "Dynamic Client Registration" to enabled
|
|
47
|
+
|
|
48
|
+
2. Configure your FastMCP server URL as a callback:
|
|
49
|
+
- Add your server URL to the Redirects tab in WorkOS dashboard
|
|
50
|
+
- Example: https://your-fastmcp-server.com/oauth2/callback
|
|
51
|
+
|
|
52
|
+
For detailed setup instructions, see:
|
|
53
|
+
https://workos.com/docs/authkit/mcp/integrating/token-verification
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
```python
|
|
57
|
+
from fastmcp.server.auth.providers.workos import AuthKitProvider
|
|
58
|
+
|
|
59
|
+
# Create WorkOS metadata provider (JWT verifier created automatically)
|
|
60
|
+
workos_auth = AuthKitProvider(
|
|
61
|
+
authkit_domain="https://your-workos-domain.authkit.app",
|
|
62
|
+
base_url="https://your-fastmcp-server.com",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Use with FastMCP
|
|
66
|
+
mcp = FastMCP("My App", auth=workos_auth)
|
|
67
|
+
```
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
authkit_domain: AnyHttpUrl | str | NotSetT = NotSet,
|
|
74
|
+
base_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
75
|
+
required_scopes: list[str] | None | NotSetT = NotSet,
|
|
76
|
+
token_verifier: TokenVerifier | None = None,
|
|
77
|
+
):
|
|
78
|
+
"""Initialize WorkOS metadata provider.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
authkit_domain: Your WorkOS AuthKit domain (e.g., "https://your-app.authkit.app")
|
|
82
|
+
base_url: Public URL of this FastMCP server
|
|
83
|
+
required_scopes: Optional list of scopes to require for all requests
|
|
84
|
+
token_verifier: Optional token verifier. If None, creates JWT verifier for WorkOS
|
|
85
|
+
"""
|
|
86
|
+
super().__init__()
|
|
87
|
+
|
|
88
|
+
settings = AuthKitProviderSettings.model_validate(
|
|
89
|
+
{
|
|
90
|
+
k: v
|
|
91
|
+
for k, v in {
|
|
92
|
+
"authkit_domain": authkit_domain,
|
|
93
|
+
"base_url": base_url,
|
|
94
|
+
"required_scopes": required_scopes,
|
|
95
|
+
}.items()
|
|
96
|
+
if v is not NotSet
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
self.authkit_domain = str(settings.authkit_domain).rstrip("/")
|
|
101
|
+
self.base_url = str(settings.base_url).rstrip("/")
|
|
102
|
+
|
|
103
|
+
# Create default JWT verifier if none provided
|
|
104
|
+
if token_verifier is None:
|
|
105
|
+
token_verifier = JWTVerifier(
|
|
106
|
+
jwks_uri=f"{self.authkit_domain}/oauth2/jwks",
|
|
107
|
+
issuer=self.authkit_domain,
|
|
108
|
+
algorithm="RS256",
|
|
109
|
+
required_scopes=settings.required_scopes,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
self.token_verifier = token_verifier
|
|
113
|
+
|
|
114
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
115
|
+
"""Verify a WorkOS token using the configured token verifier."""
|
|
116
|
+
return await self.token_verifier.verify_token(token)
|
|
117
|
+
|
|
118
|
+
def customize_auth_routes(self, routes: list[BaseRoute]) -> list[BaseRoute]:
|
|
119
|
+
"""Add AuthKit metadata endpoints.
|
|
120
|
+
|
|
121
|
+
This adds:
|
|
122
|
+
- /.well-known/oauth-authorization-server (forwards AuthKit metadata)
|
|
123
|
+
- /.well-known/oauth-protected-resource (returns FastMCP resource info)
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
async def oauth_authorization_server_metadata(request):
|
|
127
|
+
"""Forward AuthKit OAuth authorization server metadata with FastMCP customizations."""
|
|
128
|
+
try:
|
|
129
|
+
async with httpx.AsyncClient() as client:
|
|
130
|
+
response = await client.get(
|
|
131
|
+
f"{self.authkit_domain}/.well-known/oauth-authorization-server"
|
|
132
|
+
)
|
|
133
|
+
response.raise_for_status()
|
|
134
|
+
metadata = response.json()
|
|
135
|
+
return JSONResponse(metadata)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
return JSONResponse(
|
|
138
|
+
{
|
|
139
|
+
"error": "server_error",
|
|
140
|
+
"error_description": f"Failed to fetch AuthKit metadata: {e}",
|
|
141
|
+
},
|
|
142
|
+
status_code=500,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async def oauth_protected_resource_metadata(request):
|
|
146
|
+
"""Return FastMCP resource server metadata."""
|
|
147
|
+
return JSONResponse(
|
|
148
|
+
{
|
|
149
|
+
"resource": self.base_url,
|
|
150
|
+
"authorization_servers": [self.authkit_domain],
|
|
151
|
+
"bearer_methods_supported": ["header"],
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
routes.extend(
|
|
156
|
+
[
|
|
157
|
+
Route(
|
|
158
|
+
"/.well-known/oauth-authorization-server",
|
|
159
|
+
endpoint=oauth_authorization_server_metadata,
|
|
160
|
+
methods=["GET"],
|
|
161
|
+
),
|
|
162
|
+
Route(
|
|
163
|
+
"/.well-known/oauth-protected-resource",
|
|
164
|
+
endpoint=oauth_protected_resource_metadata,
|
|
165
|
+
methods=["GET"],
|
|
166
|
+
),
|
|
167
|
+
]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return routes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Provider registry for FastMCP auth providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from fastmcp.server.auth.auth import AuthProvider
|
|
10
|
+
|
|
11
|
+
# Type variable for auth providers
|
|
12
|
+
T = TypeVar("T", bound="AuthProvider")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Provider Registry
|
|
16
|
+
_PROVIDER_REGISTRY: dict[str, type[AuthProvider]] = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register_provider(name: str) -> Callable[[type[T]], type[T]]:
|
|
20
|
+
"""Decorator to register an auth provider with a given name.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
name: The name to register the provider under (e.g., 'AUTHKIT')
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
The decorated class
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
@register_provider('AUTHKIT')
|
|
30
|
+
class AuthKitProvider(AuthProvider):
|
|
31
|
+
...
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def decorator(cls: type[T]) -> type[T]:
|
|
35
|
+
_PROVIDER_REGISTRY[name.upper()] = cls
|
|
36
|
+
return cls
|
|
37
|
+
|
|
38
|
+
return decorator
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_registered_provider(name: str) -> type[AuthProvider]:
|
|
42
|
+
"""Get a registered provider by name.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
name: The provider name (case-insensitive)
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The provider class if found, None otherwise
|
|
49
|
+
"""
|
|
50
|
+
if name.upper() in _PROVIDER_REGISTRY:
|
|
51
|
+
return _PROVIDER_REGISTRY[name.upper()]
|
|
52
|
+
raise ValueError(f"Provider {name!r} has not been registered.")
|
fastmcp/server/context.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import copy
|
|
4
5
|
import warnings
|
|
5
|
-
from collections.abc import Generator
|
|
6
|
+
from collections.abc import Generator, Mapping
|
|
6
7
|
from contextlib import contextmanager
|
|
7
8
|
from contextvars import ContextVar, Token
|
|
8
9
|
from dataclasses import dataclass
|
|
@@ -46,6 +47,18 @@ _current_context: ContextVar[Context | None] = ContextVar("context", default=Non
|
|
|
46
47
|
_flush_lock = asyncio.Lock()
|
|
47
48
|
|
|
48
49
|
|
|
50
|
+
@dataclass
|
|
51
|
+
class LogData:
|
|
52
|
+
"""Data object for passing log arguments to client-side handlers.
|
|
53
|
+
|
|
54
|
+
This provides an interface to match the Python standard library logging,
|
|
55
|
+
for compatibility with structured logging.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
msg: str
|
|
59
|
+
extra: Mapping[str, Any] | None = None
|
|
60
|
+
|
|
61
|
+
|
|
49
62
|
@contextmanager
|
|
50
63
|
def set_context(context: Context) -> Generator[Context, None, None]:
|
|
51
64
|
token = _current_context.set(context)
|
|
@@ -83,9 +96,19 @@ class Context:
|
|
|
83
96
|
request_id = ctx.request_id
|
|
84
97
|
client_id = ctx.client_id
|
|
85
98
|
|
|
99
|
+
# Manage state across the request
|
|
100
|
+
ctx.set_state("key", "value")
|
|
101
|
+
value = ctx.get_state("key")
|
|
102
|
+
|
|
86
103
|
return str(x)
|
|
87
104
|
```
|
|
88
105
|
|
|
106
|
+
State Management:
|
|
107
|
+
Context objects maintain a state dictionary that can be used to store and share
|
|
108
|
+
data across middleware and tool calls within a request. When a new context
|
|
109
|
+
is created (nested contexts), it inherits a copy of its parent's state, ensuring
|
|
110
|
+
that modifications in child contexts don't affect parent contexts.
|
|
111
|
+
|
|
89
112
|
The context parameter name can be anything as long as it's annotated with Context.
|
|
90
113
|
The context is optional - tools that don't need it can omit the parameter.
|
|
91
114
|
|
|
@@ -95,9 +118,15 @@ class Context:
|
|
|
95
118
|
self.fastmcp = fastmcp
|
|
96
119
|
self._tokens: list[Token] = []
|
|
97
120
|
self._notification_queue: set[str] = set() # Dedupe notifications
|
|
121
|
+
self._state: dict[str, Any] = {}
|
|
98
122
|
|
|
99
123
|
async def __aenter__(self) -> Context:
|
|
100
124
|
"""Enter the context manager and set this context as the current context."""
|
|
125
|
+
parent_context = _current_context.get(None)
|
|
126
|
+
if parent_context is not None:
|
|
127
|
+
# Inherit state from parent context
|
|
128
|
+
self._state = copy.deepcopy(parent_context._state)
|
|
129
|
+
|
|
101
130
|
# Always set this context and save the token
|
|
102
131
|
token = _current_context.set(self)
|
|
103
132
|
self._tokens.append(token)
|
|
@@ -113,7 +142,7 @@ class Context:
|
|
|
113
142
|
_current_context.reset(token)
|
|
114
143
|
|
|
115
144
|
@property
|
|
116
|
-
def request_context(self) -> RequestContext:
|
|
145
|
+
def request_context(self) -> RequestContext[ServerSession, Any, Request]:
|
|
117
146
|
"""Access to the underlying request context.
|
|
118
147
|
|
|
119
148
|
If called outside of a request context, this will raise a ValueError.
|
|
@@ -167,6 +196,7 @@ class Context:
|
|
|
167
196
|
message: str,
|
|
168
197
|
level: LoggingLevel | None = None,
|
|
169
198
|
logger_name: str | None = None,
|
|
199
|
+
extra: Mapping[str, Any] | None = None,
|
|
170
200
|
) -> None:
|
|
171
201
|
"""Send a log message to the client.
|
|
172
202
|
|
|
@@ -175,12 +205,14 @@ class Context:
|
|
|
175
205
|
level: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical",
|
|
176
206
|
"alert", or "emergency". Default is "info".
|
|
177
207
|
logger_name: Optional logger name
|
|
208
|
+
extra: Optional mapping for additional arguments
|
|
178
209
|
"""
|
|
179
210
|
if level is None:
|
|
180
211
|
level = "info"
|
|
212
|
+
data = LogData(msg=message, extra=extra)
|
|
181
213
|
await self.session.send_log_message(
|
|
182
214
|
level=level,
|
|
183
|
-
data=
|
|
215
|
+
data=data,
|
|
184
216
|
logger=logger_name,
|
|
185
217
|
related_request_id=self.request_id,
|
|
186
218
|
)
|
|
@@ -200,35 +232,48 @@ class Context:
|
|
|
200
232
|
return str(self.request_context.request_id)
|
|
201
233
|
|
|
202
234
|
@property
|
|
203
|
-
def session_id(self) -> str
|
|
204
|
-
"""Get the MCP session ID for
|
|
235
|
+
def session_id(self) -> str:
|
|
236
|
+
"""Get the MCP session ID for ALL transports.
|
|
205
237
|
|
|
206
238
|
Returns the session ID that can be used as a key for session-based
|
|
207
239
|
data storage (e.g., Redis) to share data between tool calls within
|
|
208
240
|
the same client session.
|
|
209
241
|
|
|
210
242
|
Returns:
|
|
211
|
-
The session ID for
|
|
212
|
-
for
|
|
243
|
+
The session ID for StreamableHTTP transports, or a generated ID
|
|
244
|
+
for other transports.
|
|
213
245
|
|
|
214
246
|
Example:
|
|
215
247
|
```python
|
|
216
248
|
@server.tool
|
|
217
249
|
def store_data(data: dict, ctx: Context) -> str:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
return "No session ID available (stdio/memory transport)"
|
|
250
|
+
session_id = ctx.session_id
|
|
251
|
+
redis_client.set(f"session:{session_id}:data", json.dumps(data))
|
|
252
|
+
return f"Data stored for session {session_id}"
|
|
222
253
|
```
|
|
223
254
|
"""
|
|
224
|
-
|
|
225
|
-
|
|
255
|
+
request_ctx = self.request_context
|
|
256
|
+
session = request_ctx.session
|
|
226
257
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
258
|
+
# Try to get the session ID from the session attributes
|
|
259
|
+
session_id = getattr(session, "_fastmcp_id", None)
|
|
260
|
+
if session_id is not None:
|
|
261
|
+
return session_id
|
|
262
|
+
|
|
263
|
+
# Try to get the session ID from the http request headers
|
|
264
|
+
request = request_ctx.request
|
|
265
|
+
if request:
|
|
266
|
+
session_id = request.headers.get("mcp-session-id")
|
|
267
|
+
|
|
268
|
+
# Generate a session ID if it doesn't exist.
|
|
269
|
+
if session_id is None:
|
|
270
|
+
from uuid import uuid4
|
|
271
|
+
|
|
272
|
+
session_id = str(uuid4())
|
|
273
|
+
|
|
274
|
+
# Save the session id to the session attributes
|
|
275
|
+
setattr(session, "_fastmcp_id", session_id)
|
|
276
|
+
return session_id
|
|
232
277
|
|
|
233
278
|
@property
|
|
234
279
|
def session(self) -> ServerSession:
|
|
@@ -236,21 +281,49 @@ class Context:
|
|
|
236
281
|
return self.request_context.session
|
|
237
282
|
|
|
238
283
|
# Convenience methods for common log levels
|
|
239
|
-
async def debug(
|
|
284
|
+
async def debug(
|
|
285
|
+
self,
|
|
286
|
+
message: str,
|
|
287
|
+
logger_name: str | None = None,
|
|
288
|
+
extra: Mapping[str, Any] | None = None,
|
|
289
|
+
) -> None:
|
|
240
290
|
"""Send a debug log message."""
|
|
241
|
-
await self.log(
|
|
291
|
+
await self.log(
|
|
292
|
+
level="debug", message=message, logger_name=logger_name, extra=extra
|
|
293
|
+
)
|
|
242
294
|
|
|
243
|
-
async def info(
|
|
295
|
+
async def info(
|
|
296
|
+
self,
|
|
297
|
+
message: str,
|
|
298
|
+
logger_name: str | None = None,
|
|
299
|
+
extra: Mapping[str, Any] | None = None,
|
|
300
|
+
) -> None:
|
|
244
301
|
"""Send an info log message."""
|
|
245
|
-
await self.log(
|
|
302
|
+
await self.log(
|
|
303
|
+
level="info", message=message, logger_name=logger_name, extra=extra
|
|
304
|
+
)
|
|
246
305
|
|
|
247
|
-
async def warning(
|
|
306
|
+
async def warning(
|
|
307
|
+
self,
|
|
308
|
+
message: str,
|
|
309
|
+
logger_name: str | None = None,
|
|
310
|
+
extra: Mapping[str, Any] | None = None,
|
|
311
|
+
) -> None:
|
|
248
312
|
"""Send a warning log message."""
|
|
249
|
-
await self.log(
|
|
313
|
+
await self.log(
|
|
314
|
+
level="warning", message=message, logger_name=logger_name, extra=extra
|
|
315
|
+
)
|
|
250
316
|
|
|
251
|
-
async def error(
|
|
317
|
+
async def error(
|
|
318
|
+
self,
|
|
319
|
+
message: str,
|
|
320
|
+
logger_name: str | None = None,
|
|
321
|
+
extra: Mapping[str, Any] | None = None,
|
|
322
|
+
) -> None:
|
|
252
323
|
"""Send an error log message."""
|
|
253
|
-
await self.log(
|
|
324
|
+
await self.log(
|
|
325
|
+
level="error", message=message, logger_name=logger_name, extra=extra
|
|
326
|
+
)
|
|
254
327
|
|
|
255
328
|
async def list_roots(self) -> list[Root]:
|
|
256
329
|
"""List the roots available to the server, as indicated by the client."""
|
|
@@ -455,6 +528,14 @@ class Context:
|
|
|
455
528
|
|
|
456
529
|
return fastmcp.server.dependencies.get_http_request()
|
|
457
530
|
|
|
531
|
+
def set_state(self, key: str, value: Any) -> None:
|
|
532
|
+
"""Set a value in the context state."""
|
|
533
|
+
self._state[key] = value
|
|
534
|
+
|
|
535
|
+
def get_state(self, key: str) -> Any:
|
|
536
|
+
"""Get a value from the context state. Returns None if the key is not found."""
|
|
537
|
+
return self._state.get(key)
|
|
538
|
+
|
|
458
539
|
def _queue_tool_list_changed(self) -> None:
|
|
459
540
|
"""Queue a tool list changed notification."""
|
|
460
541
|
self._notification_queue.add("notifications/tools/list_changed")
|
fastmcp/server/dependencies.py
CHANGED
|
@@ -37,9 +37,14 @@ def get_context() -> Context:
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def get_http_request() -> Request:
|
|
40
|
-
from
|
|
40
|
+
from mcp.server.lowlevel.server import request_ctx
|
|
41
|
+
|
|
42
|
+
request = None
|
|
43
|
+
try:
|
|
44
|
+
request = request_ctx.get().request
|
|
45
|
+
except LookupError:
|
|
46
|
+
pass
|
|
41
47
|
|
|
42
|
-
request = _current_http_request.get()
|
|
43
48
|
if request is None:
|
|
44
49
|
raise RuntimeError("No active HTTP request found.")
|
|
45
50
|
return request
|
|
@@ -72,6 +77,8 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]:
|
|
|
72
77
|
"proxy-authenticate",
|
|
73
78
|
"proxy-authorization",
|
|
74
79
|
"proxy-connection",
|
|
80
|
+
# MCP-related headers
|
|
81
|
+
"mcp-session-id",
|
|
75
82
|
}
|
|
76
83
|
# (just in case)
|
|
77
84
|
if not all(h.lower() == h for h in exclude_headers):
|
fastmcp/server/http.py
CHANGED
|
@@ -3,18 +3,20 @@ from __future__ import annotations
|
|
|
3
3
|
from collections.abc import AsyncGenerator, Callable, Generator
|
|
4
4
|
from contextlib import asynccontextmanager, contextmanager
|
|
5
5
|
from contextvars import ContextVar
|
|
6
|
-
from typing import TYPE_CHECKING
|
|
6
|
+
from typing import TYPE_CHECKING, cast
|
|
7
7
|
|
|
8
8
|
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
|
|
9
9
|
from mcp.server.auth.middleware.bearer_auth import (
|
|
10
10
|
BearerAuthBackend,
|
|
11
11
|
RequireAuthMiddleware,
|
|
12
12
|
)
|
|
13
|
+
from mcp.server.auth.provider import TokenVerifier as TokenVerifierProtocol
|
|
13
14
|
from mcp.server.auth.routes import create_auth_routes
|
|
14
15
|
from mcp.server.lowlevel.server import LifespanResultT
|
|
15
16
|
from mcp.server.sse import SseServerTransport
|
|
16
17
|
from mcp.server.streamable_http import EventStore
|
|
17
18
|
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
19
|
+
from pydantic import AnyHttpUrl
|
|
18
20
|
from starlette.applications import Starlette
|
|
19
21
|
from starlette.middleware import Middleware
|
|
20
22
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
|
@@ -23,7 +25,7 @@ from starlette.responses import Response
|
|
|
23
25
|
from starlette.routing import BaseRoute, Mount, Route
|
|
24
26
|
from starlette.types import Lifespan, Receive, Scope, Send
|
|
25
27
|
|
|
26
|
-
from fastmcp.server.auth.auth import OAuthProvider
|
|
28
|
+
from fastmcp.server.auth.auth import AuthProvider, OAuthProvider, TokenVerifier
|
|
27
29
|
from fastmcp.utilities.logging import get_logger
|
|
28
30
|
|
|
29
31
|
if TYPE_CHECKING:
|
|
@@ -70,39 +72,46 @@ class RequestContextMiddleware:
|
|
|
70
72
|
|
|
71
73
|
|
|
72
74
|
def setup_auth_middleware_and_routes(
|
|
73
|
-
auth:
|
|
74
|
-
) -> tuple[list[Middleware], list[
|
|
75
|
+
auth: AuthProvider,
|
|
76
|
+
) -> tuple[list[Middleware], list[Route], list[str]]:
|
|
75
77
|
"""Set up authentication middleware and routes if auth is enabled.
|
|
76
78
|
|
|
77
79
|
Args:
|
|
78
|
-
auth:
|
|
80
|
+
auth: An AuthProvider for authentication (TokenVerifier or OAuthProvider)
|
|
79
81
|
|
|
80
82
|
Returns:
|
|
81
83
|
Tuple of (middleware, auth_routes, required_scopes)
|
|
82
84
|
"""
|
|
83
|
-
middleware: list[Middleware] = [
|
|
84
|
-
auth_routes: list[BaseRoute] = []
|
|
85
|
-
required_scopes: list[str] = []
|
|
86
|
-
|
|
87
|
-
middleware = [
|
|
85
|
+
middleware: list[Middleware] = [
|
|
88
86
|
Middleware(
|
|
89
87
|
AuthenticationMiddleware,
|
|
90
|
-
backend=BearerAuthBackend(auth),
|
|
88
|
+
backend=BearerAuthBackend(cast(TokenVerifierProtocol, auth)),
|
|
91
89
|
),
|
|
92
90
|
Middleware(AuthContextMiddleware),
|
|
93
91
|
]
|
|
94
92
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
93
|
+
auth_routes: list[Route] = []
|
|
94
|
+
required_scopes: list[str] = auth.required_scopes or []
|
|
95
|
+
|
|
96
|
+
# Check if it's an OAuthProvider (has OAuth server capability)
|
|
97
|
+
if isinstance(auth, OAuthProvider):
|
|
98
|
+
# OAuthProvider: create standard OAuth routes first
|
|
99
|
+
standard_routes = list(
|
|
100
|
+
create_auth_routes(
|
|
101
|
+
provider=auth,
|
|
102
|
+
issuer_url=auth.issuer_url,
|
|
103
|
+
service_documentation_url=auth.service_documentation_url,
|
|
104
|
+
client_registration_options=auth.client_registration_options,
|
|
105
|
+
revocation_options=auth.revocation_options,
|
|
106
|
+
)
|
|
104
107
|
)
|
|
105
|
-
|
|
108
|
+
|
|
109
|
+
# Allow provider to customize routes (e.g., for proxy behavior or metadata endpoints)
|
|
110
|
+
auth_routes = auth.customize_auth_routes(standard_routes)
|
|
111
|
+
else:
|
|
112
|
+
# Simple AuthProvider or TokenVerifier: start with empty routes
|
|
113
|
+
# Allow provider to add custom routes (e.g., metadata endpoints)
|
|
114
|
+
auth_routes = auth.customize_auth_routes([])
|
|
106
115
|
|
|
107
116
|
return middleware, auth_routes, required_scopes
|
|
108
117
|
|
|
@@ -139,7 +148,7 @@ def create_sse_app(
|
|
|
139
148
|
server: FastMCP[LifespanResultT],
|
|
140
149
|
message_path: str,
|
|
141
150
|
sse_path: str,
|
|
142
|
-
auth:
|
|
151
|
+
auth: AuthProvider | None = None,
|
|
143
152
|
debug: bool = False,
|
|
144
153
|
routes: list[BaseRoute] | None = None,
|
|
145
154
|
middleware: list[Middleware] | None = None,
|
|
@@ -150,7 +159,7 @@ def create_sse_app(
|
|
|
150
159
|
server: The FastMCP server instance
|
|
151
160
|
message_path: Path for SSE messages
|
|
152
161
|
sse_path: Path for SSE connections
|
|
153
|
-
auth: Optional
|
|
162
|
+
auth: Optional authentication provider (AuthProvider)
|
|
154
163
|
debug: Whether to enable debug mode
|
|
155
164
|
routes: Optional list of custom routes
|
|
156
165
|
middleware: Optional list of middleware
|
|
@@ -175,8 +184,6 @@ def create_sse_app(
|
|
|
175
184
|
return Response()
|
|
176
185
|
|
|
177
186
|
# Get auth middleware and routes
|
|
178
|
-
|
|
179
|
-
# Add SSE routes with or without auth
|
|
180
187
|
if auth:
|
|
181
188
|
auth_middleware, auth_routes, required_scopes = (
|
|
182
189
|
setup_auth_middleware_and_routes(auth)
|
|
@@ -184,18 +191,32 @@ def create_sse_app(
|
|
|
184
191
|
|
|
185
192
|
server_routes.extend(auth_routes)
|
|
186
193
|
server_middleware.extend(auth_middleware)
|
|
194
|
+
|
|
195
|
+
# Determine resource_metadata_url for TokenVerifier
|
|
196
|
+
resource_metadata_url = None
|
|
197
|
+
if isinstance(auth, TokenVerifier) and auth.resource_server_url:
|
|
198
|
+
# Add .well-known path for RFC 9728 compliance
|
|
199
|
+
resource_metadata_url = AnyHttpUrl(
|
|
200
|
+
str(auth.resource_server_url).rstrip("/")
|
|
201
|
+
+ "/.well-known/oauth-protected-resource"
|
|
202
|
+
)
|
|
203
|
+
|
|
187
204
|
# Auth is enabled, wrap endpoints with RequireAuthMiddleware
|
|
188
205
|
server_routes.append(
|
|
189
206
|
Route(
|
|
190
207
|
sse_path,
|
|
191
|
-
endpoint=RequireAuthMiddleware(
|
|
208
|
+
endpoint=RequireAuthMiddleware(
|
|
209
|
+
handle_sse, required_scopes, resource_metadata_url
|
|
210
|
+
),
|
|
192
211
|
methods=["GET"],
|
|
193
212
|
)
|
|
194
213
|
)
|
|
195
214
|
server_routes.append(
|
|
196
215
|
Mount(
|
|
197
216
|
message_path,
|
|
198
|
-
app=RequireAuthMiddleware(
|
|
217
|
+
app=RequireAuthMiddleware(
|
|
218
|
+
sse.handle_post_message, required_scopes, resource_metadata_url
|
|
219
|
+
),
|
|
199
220
|
)
|
|
200
221
|
)
|
|
201
222
|
else:
|
|
@@ -243,7 +264,7 @@ def create_streamable_http_app(
|
|
|
243
264
|
server: FastMCP[LifespanResultT],
|
|
244
265
|
streamable_http_path: str,
|
|
245
266
|
event_store: EventStore | None = None,
|
|
246
|
-
auth:
|
|
267
|
+
auth: AuthProvider | None = None,
|
|
247
268
|
json_response: bool = False,
|
|
248
269
|
stateless_http: bool = False,
|
|
249
270
|
debug: bool = False,
|
|
@@ -256,7 +277,7 @@ def create_streamable_http_app(
|
|
|
256
277
|
server: The FastMCP server instance
|
|
257
278
|
streamable_http_path: Path for StreamableHTTP connections
|
|
258
279
|
event_store: Optional event store for session management
|
|
259
|
-
auth: Optional
|
|
280
|
+
auth: Optional authentication provider (AuthProvider)
|
|
260
281
|
json_response: Whether to use JSON response format
|
|
261
282
|
stateless_http: Whether to use stateless mode (new transport per request)
|
|
262
283
|
debug: Whether to enable debug mode
|
|
@@ -314,11 +335,22 @@ def create_streamable_http_app(
|
|
|
314
335
|
server_routes.extend(auth_routes)
|
|
315
336
|
server_middleware.extend(auth_middleware)
|
|
316
337
|
|
|
338
|
+
# Determine resource_metadata_url for TokenVerifier
|
|
339
|
+
resource_metadata_url = None
|
|
340
|
+
if isinstance(auth, TokenVerifier) and auth.resource_server_url:
|
|
341
|
+
# Add .well-known path for RFC 9728 compliance
|
|
342
|
+
resource_metadata_url = AnyHttpUrl(
|
|
343
|
+
str(auth.resource_server_url).rstrip("/")
|
|
344
|
+
+ "/.well-known/oauth-protected-resource"
|
|
345
|
+
)
|
|
346
|
+
|
|
317
347
|
# Auth is enabled, wrap endpoint with RequireAuthMiddleware
|
|
318
348
|
server_routes.append(
|
|
319
349
|
Mount(
|
|
320
350
|
streamable_http_path,
|
|
321
|
-
app=RequireAuthMiddleware(
|
|
351
|
+
app=RequireAuthMiddleware(
|
|
352
|
+
handle_streamable_http, required_scopes, resource_metadata_url
|
|
353
|
+
),
|
|
322
354
|
)
|
|
323
355
|
)
|
|
324
356
|
else:
|