fastmcp 2.12.5__py3-none-any.whl → 2.14.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 +2 -23
- fastmcp/cli/__init__.py +0 -3
- fastmcp/cli/__main__.py +5 -0
- fastmcp/cli/cli.py +19 -33
- fastmcp/cli/install/claude_code.py +6 -6
- fastmcp/cli/install/claude_desktop.py +3 -3
- fastmcp/cli/install/cursor.py +18 -12
- fastmcp/cli/install/gemini_cli.py +3 -3
- fastmcp/cli/install/mcp_json.py +3 -3
- fastmcp/cli/install/shared.py +0 -15
- fastmcp/cli/run.py +13 -8
- fastmcp/cli/tasks.py +110 -0
- fastmcp/client/__init__.py +9 -9
- fastmcp/client/auth/oauth.py +123 -225
- fastmcp/client/client.py +697 -95
- fastmcp/client/elicitation.py +11 -5
- fastmcp/client/logging.py +18 -14
- fastmcp/client/messages.py +7 -5
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/roots.py +2 -1
- fastmcp/client/sampling.py +1 -1
- fastmcp/client/tasks.py +614 -0
- fastmcp/client/transports.py +117 -30
- fastmcp/contrib/component_manager/__init__.py +1 -1
- fastmcp/contrib/component_manager/component_manager.py +2 -2
- fastmcp/contrib/component_manager/component_service.py +10 -26
- fastmcp/contrib/mcp_mixin/README.md +32 -1
- fastmcp/contrib/mcp_mixin/__init__.py +2 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
- fastmcp/dependencies.py +25 -0
- fastmcp/experimental/sampling/handlers/openai.py +3 -3
- fastmcp/experimental/server/openapi/__init__.py +20 -21
- fastmcp/experimental/utilities/openapi/__init__.py +16 -47
- fastmcp/mcp_config.py +3 -4
- fastmcp/prompts/__init__.py +1 -1
- fastmcp/prompts/prompt.py +54 -51
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/__init__.py +5 -5
- fastmcp/resources/resource.py +43 -21
- fastmcp/resources/resource_manager.py +9 -168
- fastmcp/resources/template.py +161 -61
- fastmcp/resources/types.py +30 -24
- fastmcp/server/__init__.py +1 -1
- fastmcp/server/auth/__init__.py +9 -14
- fastmcp/server/auth/auth.py +197 -46
- fastmcp/server/auth/handlers/authorize.py +326 -0
- fastmcp/server/auth/jwt_issuer.py +236 -0
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +1469 -298
- fastmcp/server/auth/oidc_proxy.py +91 -20
- fastmcp/server/auth/providers/auth0.py +40 -21
- fastmcp/server/auth/providers/aws.py +29 -3
- fastmcp/server/auth/providers/azure.py +312 -131
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +86 -29
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/github.py +29 -8
- fastmcp/server/auth/providers/google.py +48 -9
- fastmcp/server/auth/providers/in_memory.py +29 -5
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +48 -31
- fastmcp/server/auth/providers/oci.py +233 -0
- fastmcp/server/auth/providers/scalekit.py +238 -0
- fastmcp/server/auth/providers/supabase.py +188 -0
- fastmcp/server/auth/providers/workos.py +35 -17
- fastmcp/server/context.py +236 -116
- fastmcp/server/dependencies.py +503 -18
- fastmcp/server/elicitation.py +286 -48
- fastmcp/server/event_store.py +177 -0
- fastmcp/server/http.py +71 -20
- fastmcp/server/low_level.py +165 -2
- fastmcp/server/middleware/__init__.py +1 -1
- fastmcp/server/middleware/caching.py +476 -0
- fastmcp/server/middleware/error_handling.py +14 -10
- fastmcp/server/middleware/logging.py +50 -39
- fastmcp/server/middleware/middleware.py +29 -16
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/openapi/__init__.py +35 -0
- fastmcp/{experimental/server → server}/openapi/components.py +15 -10
- fastmcp/{experimental/server → server}/openapi/routing.py +3 -3
- fastmcp/{experimental/server → server}/openapi/server.py +6 -5
- fastmcp/server/proxy.py +72 -48
- fastmcp/server/server.py +1415 -733
- fastmcp/server/tasks/__init__.py +21 -0
- fastmcp/server/tasks/capabilities.py +22 -0
- fastmcp/server/tasks/config.py +89 -0
- fastmcp/server/tasks/converters.py +205 -0
- fastmcp/server/tasks/handlers.py +356 -0
- fastmcp/server/tasks/keys.py +93 -0
- fastmcp/server/tasks/protocol.py +355 -0
- fastmcp/server/tasks/subscriptions.py +205 -0
- fastmcp/settings.py +125 -113
- fastmcp/tools/__init__.py +1 -1
- fastmcp/tools/tool.py +138 -55
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +12 -21
- fastmcp/utilities/cli.py +67 -28
- fastmcp/utilities/components.py +10 -5
- fastmcp/utilities/inspect.py +79 -23
- fastmcp/utilities/json_schema.py +4 -4
- fastmcp/utilities/json_schema_type.py +8 -8
- fastmcp/utilities/logging.py +118 -8
- fastmcp/utilities/mcp_config.py +1 -2
- fastmcp/utilities/mcp_server_config/__init__.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +5 -5
- fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
- fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
- fastmcp/utilities/openapi/__init__.py +63 -0
- fastmcp/{experimental/utilities → utilities}/openapi/director.py +14 -15
- fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
- fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +7 -3
- fastmcp/{experimental/utilities → utilities}/openapi/parser.py +37 -16
- fastmcp/utilities/tests.py +92 -5
- fastmcp/utilities/types.py +86 -16
- fastmcp/utilities/ui.py +626 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/METADATA +24 -15
- fastmcp-2.14.0.dist-info/RECORD +156 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/WHEEL +1 -1
- fastmcp/cli/claude.py +0 -135
- fastmcp/server/auth/providers/bearer.py +0 -25
- fastmcp/server/openapi.py +0 -1083
- fastmcp/utilities/openapi.py +0 -1568
- fastmcp/utilities/storage.py +0 -204
- fastmcp-2.12.5.dist-info/RECORD +0 -134
- fastmcp/{experimental/server → server}/openapi/README.md +0 -0
- fastmcp/{experimental/utilities → utilities}/openapi/models.py +3 -3
- fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +2 -2
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/server/auth/auth.py
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
from urllib.parse import urlparse
|
|
4
6
|
|
|
7
|
+
from mcp.server.auth.handlers.token import TokenErrorResponse
|
|
8
|
+
from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler
|
|
9
|
+
from mcp.server.auth.json_response import PydanticJSONResponse
|
|
5
10
|
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
|
|
6
|
-
from mcp.server.auth.middleware.bearer_auth import
|
|
7
|
-
|
|
8
|
-
RequireAuthMiddleware,
|
|
9
|
-
)
|
|
11
|
+
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
|
|
12
|
+
from mcp.server.auth.middleware.client_auth import ClientAuthenticator
|
|
10
13
|
from mcp.server.auth.provider import (
|
|
11
14
|
AccessToken as _SDKAccessToken,
|
|
12
15
|
)
|
|
@@ -19,6 +22,7 @@ from mcp.server.auth.provider import (
|
|
|
19
22
|
TokenVerifier as TokenVerifierProtocol,
|
|
20
23
|
)
|
|
21
24
|
from mcp.server.auth.routes import (
|
|
25
|
+
cors_middleware,
|
|
22
26
|
create_auth_routes,
|
|
23
27
|
create_protected_resource_routes,
|
|
24
28
|
)
|
|
@@ -26,16 +30,62 @@ from mcp.server.auth.settings import (
|
|
|
26
30
|
ClientRegistrationOptions,
|
|
27
31
|
RevocationOptions,
|
|
28
32
|
)
|
|
29
|
-
from pydantic import AnyHttpUrl
|
|
33
|
+
from pydantic import AnyHttpUrl, Field
|
|
30
34
|
from starlette.middleware import Middleware
|
|
31
35
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
|
32
36
|
from starlette.routing import Route
|
|
33
37
|
|
|
38
|
+
from fastmcp.utilities.logging import get_logger
|
|
39
|
+
|
|
40
|
+
logger = get_logger(__name__)
|
|
41
|
+
|
|
34
42
|
|
|
35
43
|
class AccessToken(_SDKAccessToken):
|
|
36
44
|
"""AccessToken that includes all JWT claims."""
|
|
37
45
|
|
|
38
|
-
claims: dict[str, Any] =
|
|
46
|
+
claims: dict[str, Any] = Field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TokenHandler(_SDKTokenHandler):
|
|
50
|
+
"""TokenHandler that returns OAuth 2.1 compliant error responses.
|
|
51
|
+
|
|
52
|
+
The MCP SDK returns `unauthorized_client` for client authentication failures.
|
|
53
|
+
However, per RFC 6749 Section 5.2, authentication failures should return
|
|
54
|
+
`invalid_client` with HTTP 401, not `unauthorized_client`.
|
|
55
|
+
|
|
56
|
+
This distinction matters: `unauthorized_client` means "client exists but
|
|
57
|
+
can't do this", while `invalid_client` means "client doesn't exist or
|
|
58
|
+
credentials are wrong". Claude's OAuth client uses this to decide whether
|
|
59
|
+
to re-register.
|
|
60
|
+
|
|
61
|
+
This handler transforms 401 responses with `unauthorized_client` to use
|
|
62
|
+
`invalid_client` instead, making the error semantics correct per OAuth spec.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
async def handle(self, request: Any):
|
|
66
|
+
"""Wrap SDK handle() and transform auth error responses."""
|
|
67
|
+
response = await super().handle(request)
|
|
68
|
+
|
|
69
|
+
# Transform 401 unauthorized_client -> invalid_client
|
|
70
|
+
if response.status_code == 401:
|
|
71
|
+
try:
|
|
72
|
+
body = json.loads(response.body)
|
|
73
|
+
if body.get("error") == "unauthorized_client":
|
|
74
|
+
return PydanticJSONResponse(
|
|
75
|
+
content=TokenErrorResponse(
|
|
76
|
+
error="invalid_client",
|
|
77
|
+
error_description=body.get("error_description"),
|
|
78
|
+
),
|
|
79
|
+
status_code=401,
|
|
80
|
+
headers={
|
|
81
|
+
"Cache-Control": "no-store",
|
|
82
|
+
"Pragma": "no-cache",
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
except (json.JSONDecodeError, AttributeError):
|
|
86
|
+
pass # Not JSON or unexpected format, return as-is
|
|
87
|
+
|
|
88
|
+
return response
|
|
39
89
|
|
|
40
90
|
|
|
41
91
|
class AuthProvider(TokenVerifierProtocol):
|
|
@@ -81,10 +131,10 @@ class AuthProvider(TokenVerifierProtocol):
|
|
|
81
131
|
def get_routes(
|
|
82
132
|
self,
|
|
83
133
|
mcp_path: str | None = None,
|
|
84
|
-
mcp_endpoint: Any | None = None,
|
|
85
134
|
) -> list[Route]:
|
|
86
|
-
"""Get
|
|
135
|
+
"""Get all routes for this authentication provider.
|
|
87
136
|
|
|
137
|
+
This includes both well-known discovery routes and operational routes.
|
|
88
138
|
Each provider is responsible for creating whatever routes it needs:
|
|
89
139
|
- TokenVerifier: typically no routes (default implementation)
|
|
90
140
|
- RemoteAuthProvider: protected resource metadata routes
|
|
@@ -93,30 +143,45 @@ class AuthProvider(TokenVerifierProtocol):
|
|
|
93
143
|
|
|
94
144
|
Args:
|
|
95
145
|
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
|
96
|
-
|
|
146
|
+
This is used to advertise the resource URL in metadata, but the
|
|
147
|
+
provider does not create the actual MCP endpoint route.
|
|
97
148
|
|
|
98
149
|
Returns:
|
|
99
|
-
List of routes for this provider
|
|
150
|
+
List of all routes for this provider (excluding the MCP endpoint itself)
|
|
100
151
|
"""
|
|
152
|
+
return []
|
|
101
153
|
|
|
102
|
-
|
|
154
|
+
def get_well_known_routes(
|
|
155
|
+
self,
|
|
156
|
+
mcp_path: str | None = None,
|
|
157
|
+
) -> list[Route]:
|
|
158
|
+
"""Get well-known discovery routes for this authentication provider.
|
|
103
159
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
resource_metadata_url = self._get_resource_url(
|
|
107
|
-
"/.well-known/oauth-protected-resource"
|
|
108
|
-
)
|
|
160
|
+
This is a utility method that filters get_routes() to return only
|
|
161
|
+
well-known discovery routes (those starting with /.well-known/).
|
|
109
162
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
mcp_endpoint, self.required_scopes, resource_metadata_url
|
|
115
|
-
),
|
|
116
|
-
)
|
|
117
|
-
)
|
|
163
|
+
Well-known routes provide OAuth metadata and discovery endpoints that
|
|
164
|
+
clients use to discover authentication capabilities. These routes should
|
|
165
|
+
be mounted at the root level of the application to comply with RFC 8414
|
|
166
|
+
and RFC 9728.
|
|
118
167
|
|
|
119
|
-
|
|
168
|
+
Common well-known routes:
|
|
169
|
+
- /.well-known/oauth-authorization-server (authorization server metadata)
|
|
170
|
+
- /.well-known/oauth-protected-resource/* (protected resource metadata)
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
|
174
|
+
This is used to construct path-scoped well-known URLs.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
List of well-known discovery routes (typically mounted at root level)
|
|
178
|
+
"""
|
|
179
|
+
all_routes = self.get_routes(mcp_path)
|
|
180
|
+
return [
|
|
181
|
+
route
|
|
182
|
+
for route in all_routes
|
|
183
|
+
if isinstance(route, Route) and route.path.startswith("/.well-known/")
|
|
184
|
+
]
|
|
120
185
|
|
|
121
186
|
def get_middleware(self) -> list:
|
|
122
187
|
"""Get HTTP application-level middleware for this auth provider.
|
|
@@ -124,12 +189,13 @@ class AuthProvider(TokenVerifierProtocol):
|
|
|
124
189
|
Returns:
|
|
125
190
|
List of Starlette Middleware instances to apply to the HTTP app
|
|
126
191
|
"""
|
|
192
|
+
# TODO(ty): remove type ignores when ty supports Starlette Middleware typing
|
|
127
193
|
return [
|
|
128
194
|
Middleware(
|
|
129
|
-
AuthenticationMiddleware,
|
|
195
|
+
AuthenticationMiddleware, # type: ignore[arg-type]
|
|
130
196
|
backend=BearerAuthBackend(self),
|
|
131
197
|
),
|
|
132
|
-
Middleware(AuthContextMiddleware),
|
|
198
|
+
Middleware(AuthContextMiddleware), # type: ignore[arg-type]
|
|
133
199
|
]
|
|
134
200
|
|
|
135
201
|
def _get_resource_url(self, path: str | None = None) -> AnyHttpUrl | None:
|
|
@@ -225,14 +291,12 @@ class RemoteAuthProvider(AuthProvider):
|
|
|
225
291
|
def get_routes(
|
|
226
292
|
self,
|
|
227
293
|
mcp_path: str | None = None,
|
|
228
|
-
mcp_endpoint: Any | None = None,
|
|
229
294
|
) -> list[Route]:
|
|
230
|
-
"""Get
|
|
295
|
+
"""Get routes for this provider.
|
|
231
296
|
|
|
232
|
-
Creates protected resource metadata routes
|
|
297
|
+
Creates protected resource metadata routes (RFC 9728).
|
|
233
298
|
"""
|
|
234
|
-
|
|
235
|
-
routes = super().get_routes(mcp_path, mcp_endpoint)
|
|
299
|
+
routes = []
|
|
236
300
|
|
|
237
301
|
# Get the resource URL based on the MCP path
|
|
238
302
|
resource_url = self._get_resource_url(mcp_path)
|
|
@@ -284,20 +348,27 @@ class OAuthProvider(
|
|
|
284
348
|
required_scopes: Scopes that are required for all requests.
|
|
285
349
|
"""
|
|
286
350
|
|
|
287
|
-
# Convert URLs to proper types
|
|
288
|
-
if isinstance(base_url, str):
|
|
289
|
-
base_url = AnyHttpUrl(base_url)
|
|
290
|
-
|
|
291
351
|
super().__init__(base_url=base_url, required_scopes=required_scopes)
|
|
292
|
-
self.base_url = base_url
|
|
293
352
|
|
|
294
353
|
if issuer_url is None:
|
|
295
|
-
self.issuer_url = base_url
|
|
354
|
+
self.issuer_url = self.base_url
|
|
296
355
|
elif isinstance(issuer_url, str):
|
|
297
356
|
self.issuer_url = AnyHttpUrl(issuer_url)
|
|
298
357
|
else:
|
|
299
358
|
self.issuer_url = issuer_url
|
|
300
359
|
|
|
360
|
+
# Log if issuer_url and base_url differ (requires additional setup)
|
|
361
|
+
if (
|
|
362
|
+
self.base_url is not None
|
|
363
|
+
and self.issuer_url is not None
|
|
364
|
+
and str(self.base_url) != str(self.issuer_url)
|
|
365
|
+
):
|
|
366
|
+
logger.info(
|
|
367
|
+
f"OAuth endpoints at {self.base_url}, issuer at {self.issuer_url}. "
|
|
368
|
+
f"Ensure well-known routes are accessible at root ({self.issuer_url}/.well-known/). "
|
|
369
|
+
f"See: https://gofastmcp.com/deployment/http#mounting-authenticated-servers"
|
|
370
|
+
)
|
|
371
|
+
|
|
301
372
|
# Initialize OAuth Authorization Server Provider
|
|
302
373
|
OAuthAuthorizationServerProvider.__init__(self)
|
|
303
374
|
|
|
@@ -326,28 +397,60 @@ class OAuthProvider(
|
|
|
326
397
|
def get_routes(
|
|
327
398
|
self,
|
|
328
399
|
mcp_path: str | None = None,
|
|
329
|
-
mcp_endpoint: Any | None = None,
|
|
330
400
|
) -> list[Route]:
|
|
331
401
|
"""Get OAuth authorization server routes and optional protected resource routes.
|
|
332
402
|
|
|
333
403
|
This method creates the full set of OAuth routes including:
|
|
334
404
|
- Standard OAuth authorization server routes (/.well-known/oauth-authorization-server, /authorize, /token, etc.)
|
|
335
405
|
- Optional protected resource routes
|
|
336
|
-
- Protected MCP endpoints if provided
|
|
337
406
|
|
|
338
407
|
Returns:
|
|
339
408
|
List of OAuth routes
|
|
340
409
|
"""
|
|
341
410
|
|
|
342
411
|
# Create standard OAuth authorization server routes
|
|
343
|
-
|
|
412
|
+
# Pass base_url as issuer_url to ensure metadata declares endpoints where
|
|
413
|
+
# they're actually accessible (operational routes are mounted at
|
|
414
|
+
# base_url)
|
|
415
|
+
assert self.base_url is not None # typing check
|
|
416
|
+
assert (
|
|
417
|
+
self.issuer_url is not None
|
|
418
|
+
) # typing check (issuer_url defaults to base_url)
|
|
419
|
+
|
|
420
|
+
sdk_routes = create_auth_routes(
|
|
344
421
|
provider=self,
|
|
345
|
-
issuer_url=self.
|
|
422
|
+
issuer_url=self.base_url,
|
|
346
423
|
service_documentation_url=self.service_documentation_url,
|
|
347
424
|
client_registration_options=self.client_registration_options,
|
|
348
425
|
revocation_options=self.revocation_options,
|
|
349
426
|
)
|
|
350
427
|
|
|
428
|
+
# Replace the token endpoint with our custom handler that returns
|
|
429
|
+
# proper OAuth 2.1 error codes (invalid_client instead of unauthorized_client)
|
|
430
|
+
oauth_routes: list[Route] = []
|
|
431
|
+
for route in sdk_routes:
|
|
432
|
+
if (
|
|
433
|
+
isinstance(route, Route)
|
|
434
|
+
and route.path == "/token"
|
|
435
|
+
and route.methods is not None
|
|
436
|
+
and "POST" in route.methods
|
|
437
|
+
):
|
|
438
|
+
# Replace with our OAuth 2.1 compliant token handler
|
|
439
|
+
token_handler = TokenHandler(
|
|
440
|
+
provider=self, client_authenticator=ClientAuthenticator(self)
|
|
441
|
+
)
|
|
442
|
+
oauth_routes.append(
|
|
443
|
+
Route(
|
|
444
|
+
path="/token",
|
|
445
|
+
endpoint=cors_middleware(
|
|
446
|
+
token_handler.handle, ["POST", "OPTIONS"]
|
|
447
|
+
),
|
|
448
|
+
methods=["POST", "OPTIONS"],
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
else:
|
|
452
|
+
oauth_routes.append(route)
|
|
453
|
+
|
|
351
454
|
# Get the resource URL based on the MCP path
|
|
352
455
|
resource_url = self._get_resource_url(mcp_path)
|
|
353
456
|
|
|
@@ -361,12 +464,60 @@ class OAuthProvider(
|
|
|
361
464
|
)
|
|
362
465
|
protected_routes = create_protected_resource_routes(
|
|
363
466
|
resource_url=resource_url,
|
|
364
|
-
authorization_servers=[self.issuer_url],
|
|
467
|
+
authorization_servers=[cast(AnyHttpUrl, self.issuer_url)],
|
|
365
468
|
scopes_supported=supported_scopes,
|
|
366
469
|
)
|
|
367
470
|
oauth_routes.extend(protected_routes)
|
|
368
471
|
|
|
369
|
-
# Add
|
|
370
|
-
oauth_routes.extend(super().get_routes(mcp_path
|
|
472
|
+
# Add base routes
|
|
473
|
+
oauth_routes.extend(super().get_routes(mcp_path))
|
|
371
474
|
|
|
372
475
|
return oauth_routes
|
|
476
|
+
|
|
477
|
+
def get_well_known_routes(
|
|
478
|
+
self,
|
|
479
|
+
mcp_path: str | None = None,
|
|
480
|
+
) -> list[Route]:
|
|
481
|
+
"""Get well-known discovery routes with RFC 8414 path-aware support.
|
|
482
|
+
|
|
483
|
+
Overrides the base implementation to support path-aware authorization
|
|
484
|
+
server metadata discovery per RFC 8414. If issuer_url has a path component,
|
|
485
|
+
the authorization server metadata route is adjusted to include that path.
|
|
486
|
+
|
|
487
|
+
For example, if issuer_url is "http://example.com/api", the discovery
|
|
488
|
+
endpoint will be at "/.well-known/oauth-authorization-server/api" instead
|
|
489
|
+
of just "/.well-known/oauth-authorization-server".
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
List of well-known discovery routes
|
|
496
|
+
"""
|
|
497
|
+
routes = super().get_well_known_routes(mcp_path)
|
|
498
|
+
|
|
499
|
+
# RFC 8414: If issuer_url has a path, use path-aware discovery
|
|
500
|
+
if self.issuer_url:
|
|
501
|
+
parsed = urlparse(str(self.issuer_url))
|
|
502
|
+
issuer_path = parsed.path.rstrip("/")
|
|
503
|
+
|
|
504
|
+
if issuer_path and issuer_path != "/":
|
|
505
|
+
# Replace /.well-known/oauth-authorization-server with path-aware version
|
|
506
|
+
new_routes = []
|
|
507
|
+
for route in routes:
|
|
508
|
+
if route.path == "/.well-known/oauth-authorization-server":
|
|
509
|
+
new_path = (
|
|
510
|
+
f"/.well-known/oauth-authorization-server{issuer_path}"
|
|
511
|
+
)
|
|
512
|
+
new_routes.append(
|
|
513
|
+
Route(
|
|
514
|
+
new_path,
|
|
515
|
+
endpoint=route.endpoint,
|
|
516
|
+
methods=route.methods,
|
|
517
|
+
)
|
|
518
|
+
)
|
|
519
|
+
else:
|
|
520
|
+
new_routes.append(route)
|
|
521
|
+
return new_routes
|
|
522
|
+
|
|
523
|
+
return routes
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Enhanced authorization handler with improved error responses.
|
|
2
|
+
|
|
3
|
+
This module provides an enhanced authorization handler that wraps the MCP SDK's
|
|
4
|
+
AuthorizationHandler to provide better error messages when clients attempt to
|
|
5
|
+
authorize with unregistered client IDs.
|
|
6
|
+
|
|
7
|
+
The enhancement adds:
|
|
8
|
+
- Content negotiation: HTML for browsers, JSON for API clients
|
|
9
|
+
- Enhanced JSON responses with registration endpoint hints
|
|
10
|
+
- Styled HTML error pages with registration links/forms
|
|
11
|
+
- Link headers pointing to registration endpoints
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from mcp.server.auth.handlers.authorize import (
|
|
20
|
+
AuthorizationHandler as SDKAuthorizationHandler,
|
|
21
|
+
)
|
|
22
|
+
from pydantic import AnyHttpUrl
|
|
23
|
+
from starlette.requests import Request
|
|
24
|
+
from starlette.responses import Response
|
|
25
|
+
|
|
26
|
+
from fastmcp.utilities.logging import get_logger
|
|
27
|
+
from fastmcp.utilities.ui import (
|
|
28
|
+
INFO_BOX_STYLES,
|
|
29
|
+
TOOLTIP_STYLES,
|
|
30
|
+
create_logo,
|
|
31
|
+
create_page,
|
|
32
|
+
create_secure_html_response,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
|
|
37
|
+
|
|
38
|
+
logger = get_logger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_unregistered_client_html(
|
|
42
|
+
client_id: str,
|
|
43
|
+
registration_endpoint: str,
|
|
44
|
+
discovery_endpoint: str,
|
|
45
|
+
server_name: str | None = None,
|
|
46
|
+
server_icon_url: str | None = None,
|
|
47
|
+
title: str = "Client Not Registered",
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Create styled HTML error page for unregistered client attempts.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
client_id: The unregistered client ID that was provided
|
|
53
|
+
registration_endpoint: URL of the registration endpoint
|
|
54
|
+
discovery_endpoint: URL of the OAuth metadata discovery endpoint
|
|
55
|
+
server_name: Optional server name for branding
|
|
56
|
+
server_icon_url: Optional server icon URL
|
|
57
|
+
title: Page title
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
HTML string for the error page
|
|
61
|
+
"""
|
|
62
|
+
import html as html_module
|
|
63
|
+
|
|
64
|
+
client_id_escaped = html_module.escape(client_id)
|
|
65
|
+
|
|
66
|
+
# Main error message
|
|
67
|
+
error_box = f"""
|
|
68
|
+
<div class="info-box error">
|
|
69
|
+
<p>The client ID <code>{client_id_escaped}</code> was not found in the server's client registry.</p>
|
|
70
|
+
</div>
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
# What to do - yellow warning box
|
|
74
|
+
warning_box = """
|
|
75
|
+
<div class="info-box warning">
|
|
76
|
+
<p>Your MCP client opened this page to complete OAuth authorization,
|
|
77
|
+
but the server did not recognize its client ID. To fix this:</p>
|
|
78
|
+
<ul>
|
|
79
|
+
<li>Close this browser window</li>
|
|
80
|
+
<li>Clear authentication tokens in your MCP client (or restart it)</li>
|
|
81
|
+
<li>Try connecting again - your client should automatically re-register</li>
|
|
82
|
+
</ul>
|
|
83
|
+
</div>
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
# Help link with tooltip (similar to consent screen)
|
|
87
|
+
help_link = """
|
|
88
|
+
<div class="help-link-container">
|
|
89
|
+
<span class="help-link">
|
|
90
|
+
Why am I seeing this?
|
|
91
|
+
<span class="tooltip">
|
|
92
|
+
OAuth 2.0 requires clients to register before authorization.
|
|
93
|
+
This server returned a 400 error because the provided client
|
|
94
|
+
ID was not found.
|
|
95
|
+
<br><br>
|
|
96
|
+
In browser-delegated OAuth flows, your application cannot
|
|
97
|
+
detect this error automatically; it's waiting for a
|
|
98
|
+
callback that will never arrive. You must manually clear
|
|
99
|
+
auth tokens and reconnect.
|
|
100
|
+
</span>
|
|
101
|
+
</span>
|
|
102
|
+
</div>
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
# Build page content
|
|
106
|
+
content = f"""
|
|
107
|
+
<div class="container">
|
|
108
|
+
{create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}
|
|
109
|
+
<h1>{title}</h1>
|
|
110
|
+
{error_box}
|
|
111
|
+
{warning_box}
|
|
112
|
+
</div>
|
|
113
|
+
{help_link}
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
# Use same styles as consent page
|
|
117
|
+
additional_styles = (
|
|
118
|
+
INFO_BOX_STYLES
|
|
119
|
+
+ TOOLTIP_STYLES
|
|
120
|
+
+ """
|
|
121
|
+
/* Error variant for info-box */
|
|
122
|
+
.info-box.error {
|
|
123
|
+
background: #fef2f2;
|
|
124
|
+
border-color: #f87171;
|
|
125
|
+
}
|
|
126
|
+
.info-box.error strong {
|
|
127
|
+
color: #991b1b;
|
|
128
|
+
}
|
|
129
|
+
/* Warning variant for info-box (yellow) */
|
|
130
|
+
.info-box.warning {
|
|
131
|
+
background: #fffbeb;
|
|
132
|
+
border-color: #fbbf24;
|
|
133
|
+
}
|
|
134
|
+
.info-box.warning strong {
|
|
135
|
+
color: #92400e;
|
|
136
|
+
}
|
|
137
|
+
.info-box code {
|
|
138
|
+
background: rgba(0, 0, 0, 0.05);
|
|
139
|
+
padding: 2px 6px;
|
|
140
|
+
border-radius: 3px;
|
|
141
|
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
|
142
|
+
font-size: 0.9em;
|
|
143
|
+
}
|
|
144
|
+
.info-box ul {
|
|
145
|
+
margin: 10px 0;
|
|
146
|
+
padding-left: 20px;
|
|
147
|
+
}
|
|
148
|
+
.info-box li {
|
|
149
|
+
margin: 6px 0;
|
|
150
|
+
}
|
|
151
|
+
"""
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return create_page(
|
|
155
|
+
content=content,
|
|
156
|
+
title=title,
|
|
157
|
+
additional_styles=additional_styles,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class AuthorizationHandler(SDKAuthorizationHandler):
|
|
162
|
+
"""Authorization handler with enhanced error responses for unregistered clients.
|
|
163
|
+
|
|
164
|
+
This handler extends the MCP SDK's AuthorizationHandler to provide better UX
|
|
165
|
+
when clients attempt to authorize without being registered. It implements
|
|
166
|
+
content negotiation to return:
|
|
167
|
+
|
|
168
|
+
- HTML error pages for browser requests
|
|
169
|
+
- Enhanced JSON with registration hints for API clients
|
|
170
|
+
- Link headers pointing to registration endpoints
|
|
171
|
+
|
|
172
|
+
This maintains OAuth 2.1 compliance (returns 400 for invalid client_id)
|
|
173
|
+
while providing actionable guidance to fix the error.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
provider: OAuthAuthorizationServerProvider,
|
|
179
|
+
base_url: AnyHttpUrl | str,
|
|
180
|
+
server_name: str | None = None,
|
|
181
|
+
server_icon_url: str | None = None,
|
|
182
|
+
):
|
|
183
|
+
"""Initialize the enhanced authorization handler.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
provider: OAuth authorization server provider
|
|
187
|
+
base_url: Base URL of the server for constructing endpoint URLs
|
|
188
|
+
server_name: Optional server name for branding
|
|
189
|
+
server_icon_url: Optional server icon URL for branding
|
|
190
|
+
"""
|
|
191
|
+
super().__init__(provider)
|
|
192
|
+
self._base_url = str(base_url).rstrip("/")
|
|
193
|
+
self._server_name = server_name
|
|
194
|
+
self._server_icon_url = server_icon_url
|
|
195
|
+
|
|
196
|
+
async def handle(self, request: Request) -> Response:
|
|
197
|
+
"""Handle authorization request with enhanced error responses.
|
|
198
|
+
|
|
199
|
+
This method extends the SDK's authorization handler and intercepts
|
|
200
|
+
errors for unregistered clients to provide better error responses
|
|
201
|
+
based on the client's Accept header.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
request: The authorization request
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Response (redirect on success, error response on failure)
|
|
208
|
+
"""
|
|
209
|
+
# Call the SDK handler
|
|
210
|
+
response = await super().handle(request)
|
|
211
|
+
|
|
212
|
+
# Check if this is a client not found error
|
|
213
|
+
if response.status_code == 400:
|
|
214
|
+
# Try to extract client_id from request for enhanced error
|
|
215
|
+
client_id: str | None = None
|
|
216
|
+
if request.method == "GET":
|
|
217
|
+
client_id = request.query_params.get("client_id")
|
|
218
|
+
else:
|
|
219
|
+
form = await request.form()
|
|
220
|
+
client_id_value = form.get("client_id")
|
|
221
|
+
# Ensure client_id is a string, not UploadFile
|
|
222
|
+
if isinstance(client_id_value, str):
|
|
223
|
+
client_id = client_id_value
|
|
224
|
+
|
|
225
|
+
# If we have a client_id and the error is about it not being found,
|
|
226
|
+
# enhance the response
|
|
227
|
+
if client_id:
|
|
228
|
+
try:
|
|
229
|
+
# Check if response body contains "not found" error
|
|
230
|
+
if hasattr(response, "body"):
|
|
231
|
+
body = json.loads(bytes(response.body))
|
|
232
|
+
if (
|
|
233
|
+
body.get("error") == "invalid_request"
|
|
234
|
+
and "not found" in body.get("error_description", "").lower()
|
|
235
|
+
):
|
|
236
|
+
return await self._create_enhanced_error_response(
|
|
237
|
+
request, client_id, body.get("state")
|
|
238
|
+
)
|
|
239
|
+
except Exception:
|
|
240
|
+
# If we can't parse the response, just return the original
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
return response
|
|
244
|
+
|
|
245
|
+
async def _create_enhanced_error_response(
|
|
246
|
+
self, request: Request, client_id: str, state: str | None
|
|
247
|
+
) -> Response:
|
|
248
|
+
"""Create enhanced error response with content negotiation.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
request: The original request
|
|
252
|
+
client_id: The unregistered client ID
|
|
253
|
+
state: The state parameter from the request
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
HTML or JSON error response based on Accept header
|
|
257
|
+
"""
|
|
258
|
+
registration_endpoint = f"{self._base_url}/register"
|
|
259
|
+
discovery_endpoint = f"{self._base_url}/.well-known/oauth-authorization-server"
|
|
260
|
+
|
|
261
|
+
# Extract server metadata from app state (same pattern as consent screen)
|
|
262
|
+
from fastmcp.server.server import FastMCP
|
|
263
|
+
|
|
264
|
+
fastmcp = getattr(request.app.state, "fastmcp_server", None)
|
|
265
|
+
|
|
266
|
+
if isinstance(fastmcp, FastMCP):
|
|
267
|
+
server_name = fastmcp.name
|
|
268
|
+
icons = fastmcp.icons
|
|
269
|
+
server_icon_url = icons[0].src if icons else None
|
|
270
|
+
else:
|
|
271
|
+
server_name = self._server_name
|
|
272
|
+
server_icon_url = self._server_icon_url
|
|
273
|
+
|
|
274
|
+
# Check Accept header for content negotiation
|
|
275
|
+
accept = request.headers.get("accept", "")
|
|
276
|
+
|
|
277
|
+
# Prefer HTML for browsers
|
|
278
|
+
if "text/html" in accept:
|
|
279
|
+
html = create_unregistered_client_html(
|
|
280
|
+
client_id=client_id,
|
|
281
|
+
registration_endpoint=registration_endpoint,
|
|
282
|
+
discovery_endpoint=discovery_endpoint,
|
|
283
|
+
server_name=server_name,
|
|
284
|
+
server_icon_url=server_icon_url,
|
|
285
|
+
)
|
|
286
|
+
response = create_secure_html_response(html, status_code=400)
|
|
287
|
+
else:
|
|
288
|
+
# Return enhanced JSON for API clients
|
|
289
|
+
from mcp.server.auth.handlers.authorize import AuthorizationErrorResponse
|
|
290
|
+
|
|
291
|
+
error_data = AuthorizationErrorResponse(
|
|
292
|
+
error="invalid_request",
|
|
293
|
+
error_description=(
|
|
294
|
+
f"Client ID '{client_id}' is not registered with this server. "
|
|
295
|
+
f"MCP clients should automatically re-register by sending a POST request to "
|
|
296
|
+
f"the registration_endpoint and retry authorization. "
|
|
297
|
+
f"If this persists, clear cached authentication tokens and reconnect."
|
|
298
|
+
),
|
|
299
|
+
state=state,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Add extra fields to help clients discover registration
|
|
303
|
+
error_dict = error_data.model_dump(exclude_none=True)
|
|
304
|
+
error_dict["registration_endpoint"] = registration_endpoint
|
|
305
|
+
error_dict["authorization_server_metadata"] = discovery_endpoint
|
|
306
|
+
|
|
307
|
+
from starlette.responses import JSONResponse
|
|
308
|
+
|
|
309
|
+
response = JSONResponse(
|
|
310
|
+
status_code=400,
|
|
311
|
+
content=error_dict,
|
|
312
|
+
headers={"Cache-Control": "no-store"},
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Add Link header for registration endpoint discovery
|
|
316
|
+
response.headers["Link"] = (
|
|
317
|
+
f'<{registration_endpoint}>; rel="http://oauth.net/core/2.1/#registration"'
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
logger.info(
|
|
321
|
+
"Unregistered client_id=%s, returned %s error response",
|
|
322
|
+
client_id,
|
|
323
|
+
"HTML" if "text/html" in accept else "JSON",
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return response
|