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.
Files changed (133) hide show
  1. fastmcp/__init__.py +2 -23
  2. fastmcp/cli/__init__.py +0 -3
  3. fastmcp/cli/__main__.py +5 -0
  4. fastmcp/cli/cli.py +19 -33
  5. fastmcp/cli/install/claude_code.py +6 -6
  6. fastmcp/cli/install/claude_desktop.py +3 -3
  7. fastmcp/cli/install/cursor.py +18 -12
  8. fastmcp/cli/install/gemini_cli.py +3 -3
  9. fastmcp/cli/install/mcp_json.py +3 -3
  10. fastmcp/cli/install/shared.py +0 -15
  11. fastmcp/cli/run.py +13 -8
  12. fastmcp/cli/tasks.py +110 -0
  13. fastmcp/client/__init__.py +9 -9
  14. fastmcp/client/auth/oauth.py +123 -225
  15. fastmcp/client/client.py +697 -95
  16. fastmcp/client/elicitation.py +11 -5
  17. fastmcp/client/logging.py +18 -14
  18. fastmcp/client/messages.py +7 -5
  19. fastmcp/client/oauth_callback.py +85 -171
  20. fastmcp/client/roots.py +2 -1
  21. fastmcp/client/sampling.py +1 -1
  22. fastmcp/client/tasks.py +614 -0
  23. fastmcp/client/transports.py +117 -30
  24. fastmcp/contrib/component_manager/__init__.py +1 -1
  25. fastmcp/contrib/component_manager/component_manager.py +2 -2
  26. fastmcp/contrib/component_manager/component_service.py +10 -26
  27. fastmcp/contrib/mcp_mixin/README.md +32 -1
  28. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  29. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
  30. fastmcp/dependencies.py +25 -0
  31. fastmcp/experimental/sampling/handlers/openai.py +3 -3
  32. fastmcp/experimental/server/openapi/__init__.py +20 -21
  33. fastmcp/experimental/utilities/openapi/__init__.py +16 -47
  34. fastmcp/mcp_config.py +3 -4
  35. fastmcp/prompts/__init__.py +1 -1
  36. fastmcp/prompts/prompt.py +54 -51
  37. fastmcp/prompts/prompt_manager.py +16 -101
  38. fastmcp/resources/__init__.py +5 -5
  39. fastmcp/resources/resource.py +43 -21
  40. fastmcp/resources/resource_manager.py +9 -168
  41. fastmcp/resources/template.py +161 -61
  42. fastmcp/resources/types.py +30 -24
  43. fastmcp/server/__init__.py +1 -1
  44. fastmcp/server/auth/__init__.py +9 -14
  45. fastmcp/server/auth/auth.py +197 -46
  46. fastmcp/server/auth/handlers/authorize.py +326 -0
  47. fastmcp/server/auth/jwt_issuer.py +236 -0
  48. fastmcp/server/auth/middleware.py +96 -0
  49. fastmcp/server/auth/oauth_proxy.py +1469 -298
  50. fastmcp/server/auth/oidc_proxy.py +91 -20
  51. fastmcp/server/auth/providers/auth0.py +40 -21
  52. fastmcp/server/auth/providers/aws.py +29 -3
  53. fastmcp/server/auth/providers/azure.py +312 -131
  54. fastmcp/server/auth/providers/debug.py +114 -0
  55. fastmcp/server/auth/providers/descope.py +86 -29
  56. fastmcp/server/auth/providers/discord.py +308 -0
  57. fastmcp/server/auth/providers/github.py +29 -8
  58. fastmcp/server/auth/providers/google.py +48 -9
  59. fastmcp/server/auth/providers/in_memory.py +29 -5
  60. fastmcp/server/auth/providers/introspection.py +281 -0
  61. fastmcp/server/auth/providers/jwt.py +48 -31
  62. fastmcp/server/auth/providers/oci.py +233 -0
  63. fastmcp/server/auth/providers/scalekit.py +238 -0
  64. fastmcp/server/auth/providers/supabase.py +188 -0
  65. fastmcp/server/auth/providers/workos.py +35 -17
  66. fastmcp/server/context.py +236 -116
  67. fastmcp/server/dependencies.py +503 -18
  68. fastmcp/server/elicitation.py +286 -48
  69. fastmcp/server/event_store.py +177 -0
  70. fastmcp/server/http.py +71 -20
  71. fastmcp/server/low_level.py +165 -2
  72. fastmcp/server/middleware/__init__.py +1 -1
  73. fastmcp/server/middleware/caching.py +476 -0
  74. fastmcp/server/middleware/error_handling.py +14 -10
  75. fastmcp/server/middleware/logging.py +50 -39
  76. fastmcp/server/middleware/middleware.py +29 -16
  77. fastmcp/server/middleware/rate_limiting.py +3 -3
  78. fastmcp/server/middleware/tool_injection.py +116 -0
  79. fastmcp/server/openapi/__init__.py +35 -0
  80. fastmcp/{experimental/server → server}/openapi/components.py +15 -10
  81. fastmcp/{experimental/server → server}/openapi/routing.py +3 -3
  82. fastmcp/{experimental/server → server}/openapi/server.py +6 -5
  83. fastmcp/server/proxy.py +72 -48
  84. fastmcp/server/server.py +1415 -733
  85. fastmcp/server/tasks/__init__.py +21 -0
  86. fastmcp/server/tasks/capabilities.py +22 -0
  87. fastmcp/server/tasks/config.py +89 -0
  88. fastmcp/server/tasks/converters.py +205 -0
  89. fastmcp/server/tasks/handlers.py +356 -0
  90. fastmcp/server/tasks/keys.py +93 -0
  91. fastmcp/server/tasks/protocol.py +355 -0
  92. fastmcp/server/tasks/subscriptions.py +205 -0
  93. fastmcp/settings.py +125 -113
  94. fastmcp/tools/__init__.py +1 -1
  95. fastmcp/tools/tool.py +138 -55
  96. fastmcp/tools/tool_manager.py +30 -112
  97. fastmcp/tools/tool_transform.py +12 -21
  98. fastmcp/utilities/cli.py +67 -28
  99. fastmcp/utilities/components.py +10 -5
  100. fastmcp/utilities/inspect.py +79 -23
  101. fastmcp/utilities/json_schema.py +4 -4
  102. fastmcp/utilities/json_schema_type.py +8 -8
  103. fastmcp/utilities/logging.py +118 -8
  104. fastmcp/utilities/mcp_config.py +1 -2
  105. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  106. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  107. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  108. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +5 -5
  109. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  110. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  111. fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
  112. fastmcp/utilities/openapi/__init__.py +63 -0
  113. fastmcp/{experimental/utilities → utilities}/openapi/director.py +14 -15
  114. fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
  115. fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +7 -3
  116. fastmcp/{experimental/utilities → utilities}/openapi/parser.py +37 -16
  117. fastmcp/utilities/tests.py +92 -5
  118. fastmcp/utilities/types.py +86 -16
  119. fastmcp/utilities/ui.py +626 -0
  120. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/METADATA +24 -15
  121. fastmcp-2.14.0.dist-info/RECORD +156 -0
  122. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/WHEEL +1 -1
  123. fastmcp/cli/claude.py +0 -135
  124. fastmcp/server/auth/providers/bearer.py +0 -25
  125. fastmcp/server/openapi.py +0 -1083
  126. fastmcp/utilities/openapi.py +0 -1568
  127. fastmcp/utilities/storage.py +0 -204
  128. fastmcp-2.12.5.dist-info/RECORD +0 -134
  129. fastmcp/{experimental/server → server}/openapi/README.md +0 -0
  130. fastmcp/{experimental/utilities → utilities}/openapi/models.py +3 -3
  131. fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +2 -2
  132. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/entry_points.txt +0 -0
  133. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,12 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any
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
- BearerAuthBackend,
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 the routes for this authentication provider.
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
- mcp_endpoint: The MCP endpoint handler to protect with auth
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, including protected MCP endpoints if provided
150
+ List of all routes for this provider (excluding the MCP endpoint itself)
100
151
  """
152
+ return []
101
153
 
102
- routes = []
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
- # Add protected MCP endpoint if provided
105
- if mcp_path and mcp_endpoint:
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
- routes.append(
111
- Route(
112
- mcp_path,
113
- endpoint=RequireAuthMiddleware(
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
- return routes
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 OAuth routes for this provider.
295
+ """Get routes for this provider.
231
296
 
232
- Creates protected resource metadata routes and optionally wraps MCP endpoints with auth.
297
+ Creates protected resource metadata routes (RFC 9728).
233
298
  """
234
- # Start with base routes (protected MCP endpoint)
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
- oauth_routes = create_auth_routes(
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.issuer_url,
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 protected MCP endpoint from base class
370
- oauth_routes.extend(super().get_routes(mcp_path, mcp_endpoint))
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