fastmcp 2.13.0rc2__py3-none-any.whl → 2.13.0.1__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 (81) hide show
  1. fastmcp/__init__.py +2 -2
  2. fastmcp/cli/cli.py +3 -2
  3. fastmcp/cli/install/claude_code.py +3 -3
  4. fastmcp/client/__init__.py +9 -9
  5. fastmcp/client/auth/oauth.py +7 -6
  6. fastmcp/client/client.py +10 -10
  7. fastmcp/client/oauth_callback.py +6 -2
  8. fastmcp/client/sampling.py +1 -1
  9. fastmcp/client/transports.py +35 -34
  10. fastmcp/contrib/component_manager/__init__.py +1 -1
  11. fastmcp/contrib/component_manager/component_manager.py +2 -2
  12. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  13. fastmcp/experimental/sampling/handlers/openai.py +2 -2
  14. fastmcp/experimental/server/openapi/__init__.py +5 -8
  15. fastmcp/experimental/server/openapi/components.py +11 -7
  16. fastmcp/experimental/server/openapi/routing.py +2 -2
  17. fastmcp/experimental/utilities/openapi/__init__.py +10 -15
  18. fastmcp/experimental/utilities/openapi/director.py +1 -1
  19. fastmcp/experimental/utilities/openapi/json_schema_converter.py +2 -2
  20. fastmcp/experimental/utilities/openapi/models.py +3 -3
  21. fastmcp/experimental/utilities/openapi/parser.py +3 -5
  22. fastmcp/experimental/utilities/openapi/schemas.py +2 -2
  23. fastmcp/mcp_config.py +2 -3
  24. fastmcp/prompts/__init__.py +1 -1
  25. fastmcp/prompts/prompt.py +9 -13
  26. fastmcp/resources/__init__.py +5 -5
  27. fastmcp/resources/resource.py +1 -3
  28. fastmcp/resources/resource_manager.py +1 -1
  29. fastmcp/resources/types.py +30 -24
  30. fastmcp/server/__init__.py +1 -1
  31. fastmcp/server/auth/__init__.py +5 -5
  32. fastmcp/server/auth/auth.py +2 -2
  33. fastmcp/server/auth/handlers/authorize.py +324 -0
  34. fastmcp/server/auth/jwt_issuer.py +39 -92
  35. fastmcp/server/auth/middleware.py +96 -0
  36. fastmcp/server/auth/oauth_proxy.py +236 -217
  37. fastmcp/server/auth/oidc_proxy.py +18 -3
  38. fastmcp/server/auth/providers/auth0.py +28 -15
  39. fastmcp/server/auth/providers/aws.py +16 -1
  40. fastmcp/server/auth/providers/azure.py +101 -40
  41. fastmcp/server/auth/providers/bearer.py +1 -1
  42. fastmcp/server/auth/providers/github.py +16 -1
  43. fastmcp/server/auth/providers/google.py +16 -1
  44. fastmcp/server/auth/providers/in_memory.py +2 -2
  45. fastmcp/server/auth/providers/introspection.py +2 -2
  46. fastmcp/server/auth/providers/jwt.py +17 -18
  47. fastmcp/server/auth/providers/supabase.py +1 -1
  48. fastmcp/server/auth/providers/workos.py +18 -3
  49. fastmcp/server/context.py +41 -12
  50. fastmcp/server/dependencies.py +5 -6
  51. fastmcp/server/elicitation.py +1 -1
  52. fastmcp/server/http.py +3 -4
  53. fastmcp/server/middleware/__init__.py +1 -1
  54. fastmcp/server/middleware/caching.py +1 -1
  55. fastmcp/server/middleware/error_handling.py +8 -8
  56. fastmcp/server/middleware/middleware.py +1 -1
  57. fastmcp/server/middleware/tool_injection.py +116 -0
  58. fastmcp/server/openapi.py +10 -6
  59. fastmcp/server/proxy.py +5 -4
  60. fastmcp/server/server.py +74 -55
  61. fastmcp/settings.py +2 -1
  62. fastmcp/tools/__init__.py +1 -1
  63. fastmcp/tools/tool.py +12 -12
  64. fastmcp/tools/tool_manager.py +8 -4
  65. fastmcp/tools/tool_transform.py +6 -6
  66. fastmcp/utilities/cli.py +50 -21
  67. fastmcp/utilities/inspect.py +2 -2
  68. fastmcp/utilities/json_schema_type.py +4 -4
  69. fastmcp/utilities/logging.py +14 -18
  70. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  71. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  72. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  73. fastmcp/utilities/openapi.py +9 -9
  74. fastmcp/utilities/tests.py +2 -4
  75. fastmcp/utilities/ui.py +126 -6
  76. {fastmcp-2.13.0rc2.dist-info → fastmcp-2.13.0.1.dist-info}/METADATA +5 -5
  77. fastmcp-2.13.0.1.dist-info/RECORD +141 -0
  78. fastmcp-2.13.0rc2.dist-info/RECORD +0 -138
  79. {fastmcp-2.13.0rc2.dist-info → fastmcp-2.13.0.1.dist-info}/WHEEL +0 -0
  80. {fastmcp-2.13.0rc2.dist-info → fastmcp-2.13.0.1.dist-info}/entry_points.txt +0 -0
  81. {fastmcp-2.13.0rc2.dist-info → fastmcp-2.13.0.1.dist-info}/licenses/LICENSE +0 -0
fastmcp/prompts/prompt.py CHANGED
@@ -207,10 +207,7 @@ class FunctionPrompt(Prompt):
207
207
  # Auto-detect context parameter if not provided
208
208
 
209
209
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
210
- if context_kwarg:
211
- prune_params = [context_kwarg]
212
- else:
213
- prune_params = None
210
+ prune_params = [context_kwarg] if context_kwarg else None
214
211
 
215
212
  parameters = compress_schema(parameters, prune_params=prune_params)
216
213
 
@@ -290,10 +287,7 @@ class FunctionPrompt(Prompt):
290
287
  if (
291
288
  param.annotation == inspect.Parameter.empty
292
289
  or param.annotation is str
293
- ):
294
- converted_kwargs[param_name] = param_value
295
- # If argument is not a string, pass as-is (already properly typed)
296
- elif not isinstance(param_value, str):
290
+ ) or not isinstance(param_value, str):
297
291
  converted_kwargs[param_name] = param_value
298
292
  else:
299
293
  # Try to convert string argument using type adapter
@@ -314,7 +308,7 @@ class FunctionPrompt(Prompt):
314
308
  raise PromptError(
315
309
  f"Could not convert argument '{param_name}' with value '{param_value}' "
316
310
  f"to expected type {param.annotation}. Error: {e}"
317
- )
311
+ ) from e
318
312
  else:
319
313
  # Parameter not in function signature, pass as-is
320
314
  converted_kwargs[param_name] = param_value
@@ -376,10 +370,12 @@ class FunctionPrompt(Prompt):
376
370
  content=TextContent(type="text", text=content),
377
371
  )
378
372
  )
379
- except Exception:
380
- raise PromptError("Could not convert prompt result to message.")
373
+ except Exception as e:
374
+ raise PromptError(
375
+ "Could not convert prompt result to message."
376
+ ) from e
381
377
 
382
378
  return messages
383
- except Exception:
379
+ except Exception as e:
384
380
  logger.exception(f"Error rendering prompt {self.name}")
385
- raise PromptError(f"Error rendering prompt {self.name}.")
381
+ raise PromptError(f"Error rendering prompt {self.name}.") from e
@@ -10,13 +10,13 @@ from .types import (
10
10
  from .resource_manager import ResourceManager
11
11
 
12
12
  __all__ = [
13
- "Resource",
14
- "TextResource",
15
13
  "BinaryResource",
16
- "FunctionResource",
14
+ "DirectoryResource",
17
15
  "FileResource",
16
+ "FunctionResource",
18
17
  "HttpResource",
19
- "DirectoryResource",
20
- "ResourceTemplate",
18
+ "Resource",
21
19
  "ResourceManager",
20
+ "ResourceTemplate",
21
+ "TextResource",
22
22
  ]
@@ -217,9 +217,7 @@ class FunctionResource(Resource):
217
217
 
218
218
  if isinstance(result, Resource):
219
219
  return await result.read()
220
- elif isinstance(result, bytes):
221
- return result
222
- elif isinstance(result, str):
220
+ elif isinstance(result, bytes | str):
223
221
  return result
224
222
  else:
225
223
  return pydantic_core.to_json(result, fallback=str).decode()
@@ -235,7 +235,7 @@ class ResourceManager:
235
235
 
236
236
  # Then check templates (local and mounted) only if not found in concrete resources
237
237
  templates = await self.get_resource_templates()
238
- for template_key in templates.keys():
238
+ for template_key in templates:
239
239
  if match_uri_template(uri_str, template_key):
240
240
  return True
241
241
 
@@ -5,11 +5,11 @@ from __future__ import annotations
5
5
  import json
6
6
  from pathlib import Path
7
7
 
8
- import anyio
9
- import anyio.to_thread
10
8
  import httpx
11
9
  import pydantic.json
10
+ from anyio import Path as AsyncPath
12
11
  from pydantic import Field, ValidationInfo
12
+ from typing_extensions import override
13
13
 
14
14
  from fastmcp.exceptions import ResourceError
15
15
  from fastmcp.resources.resource import Resource
@@ -54,6 +54,10 @@ class FileResource(Resource):
54
54
  description="MIME type of the resource content",
55
55
  )
56
56
 
57
+ @property
58
+ def _async_path(self) -> AsyncPath:
59
+ return AsyncPath(self.path)
60
+
57
61
  @pydantic.field_validator("path")
58
62
  @classmethod
59
63
  def validate_absolute_path(cls, path: Path) -> Path:
@@ -71,12 +75,13 @@ class FileResource(Resource):
71
75
  mime_type = info.data.get("mime_type", "text/plain")
72
76
  return not mime_type.startswith("text/")
73
77
 
78
+ @override
74
79
  async def read(self) -> str | bytes:
75
80
  """Read the file content."""
76
81
  try:
77
82
  if self.is_binary:
78
- return await anyio.to_thread.run_sync(self.path.read_bytes)
79
- return await anyio.to_thread.run_sync(self.path.read_text)
83
+ return await self._async_path.read_bytes()
84
+ return await self._async_path.read_text()
80
85
  except Exception as e:
81
86
  raise ResourceError(f"Error reading file {self.path}") from e
82
87
 
@@ -89,11 +94,12 @@ class HttpResource(Resource):
89
94
  default="application/json", description="MIME type of the resource content"
90
95
  )
91
96
 
97
+ @override
92
98
  async def read(self) -> str | bytes:
93
99
  """Read the HTTP content."""
94
100
  async with httpx.AsyncClient() as client:
95
101
  response = await client.get(self.url)
96
- response.raise_for_status()
102
+ _ = response.raise_for_status()
97
103
  return response.text
98
104
 
99
105
 
@@ -111,6 +117,10 @@ class DirectoryResource(Resource):
111
117
  default="application/json", description="MIME type of the resource content"
112
118
  )
113
119
 
120
+ @property
121
+ def _async_path(self) -> AsyncPath:
122
+ return AsyncPath(self.path)
123
+
114
124
  @pydantic.field_validator("path")
115
125
  @classmethod
116
126
  def validate_absolute_path(cls, path: Path) -> Path:
@@ -119,33 +129,29 @@ class DirectoryResource(Resource):
119
129
  raise ValueError("Path must be absolute")
120
130
  return path
121
131
 
122
- def list_files(self) -> list[Path]:
132
+ async def list_files(self) -> list[Path]:
123
133
  """List files in the directory."""
124
- if not self.path.exists():
134
+ if not await self._async_path.exists():
125
135
  raise FileNotFoundError(f"Directory not found: {self.path}")
126
- if not self.path.is_dir():
136
+ if not await self._async_path.is_dir():
127
137
  raise NotADirectoryError(f"Not a directory: {self.path}")
128
138
 
139
+ pattern = self.pattern or "*"
140
+
141
+ glob_fn = self._async_path.rglob if self.recursive else self._async_path.glob
129
142
  try:
130
- if self.pattern:
131
- return (
132
- list(self.path.glob(self.pattern))
133
- if not self.recursive
134
- else list(self.path.rglob(self.pattern))
135
- )
136
- return (
137
- list(self.path.glob("*"))
138
- if not self.recursive
139
- else list(self.path.rglob("*"))
140
- )
143
+ return [Path(p) async for p in glob_fn(pattern) if await p.is_file()]
141
144
  except Exception as e:
142
- raise ResourceError(f"Error listing directory {self.path}: {e}")
145
+ raise ResourceError(f"Error listing directory {self.path}") from e
143
146
 
147
+ @override
144
148
  async def read(self) -> str: # Always returns JSON string
145
149
  """Read the directory listing."""
146
150
  try:
147
- files = await anyio.to_thread.run_sync(self.list_files)
148
- file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
151
+ files: list[Path] = await self.list_files()
152
+
153
+ file_list = [str(f.relative_to(self.path)) for f in files]
154
+
149
155
  return json.dumps({"files": file_list}, indent=2)
150
- except Exception:
151
- raise ResourceError(f"Error reading directory {self.path}")
156
+ except Exception as e:
157
+ raise ResourceError(f"Error reading directory {self.path}") from e
@@ -3,4 +3,4 @@ from .context import Context
3
3
  from . import dependencies
4
4
 
5
5
 
6
- __all__ = ["FastMCP", "Context"]
6
+ __all__ = ["Context", "FastMCP"]
@@ -10,14 +10,14 @@ from .oauth_proxy import OAuthProxy
10
10
 
11
11
 
12
12
  __all__ = [
13
+ "AccessToken",
13
14
  "AuthProvider",
14
- "OAuthProvider",
15
- "TokenVerifier",
16
15
  "JWTVerifier",
17
- "StaticTokenVerifier",
18
- "RemoteAuthProvider",
19
- "AccessToken",
16
+ "OAuthProvider",
20
17
  "OAuthProxy",
18
+ "RemoteAuthProvider",
19
+ "StaticTokenVerifier",
20
+ "TokenVerifier",
21
21
  ]
22
22
 
23
23
 
@@ -23,7 +23,7 @@ from mcp.server.auth.settings import (
23
23
  ClientRegistrationOptions,
24
24
  RevocationOptions,
25
25
  )
26
- from pydantic import AnyHttpUrl
26
+ from pydantic import AnyHttpUrl, Field
27
27
  from starlette.middleware import Middleware
28
28
  from starlette.middleware.authentication import AuthenticationMiddleware
29
29
  from starlette.routing import Route
@@ -32,7 +32,7 @@ from starlette.routing import Route
32
32
  class AccessToken(_SDKAccessToken):
33
33
  """AccessToken that includes all JWT claims."""
34
34
 
35
- claims: dict[str, Any] = {}
35
+ claims: dict[str, Any] = Field(default_factory=dict)
36
36
 
37
37
 
38
38
  class AuthProvider(TokenVerifierProtocol):
@@ -0,0 +1,324 @@
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
+ from typing import TYPE_CHECKING
17
+
18
+ from mcp.server.auth.handlers.authorize import (
19
+ AuthorizationHandler as SDKAuthorizationHandler,
20
+ )
21
+ from pydantic import AnyHttpUrl
22
+ from starlette.requests import Request
23
+ from starlette.responses import Response
24
+
25
+ from fastmcp.utilities.logging import get_logger
26
+ from fastmcp.utilities.ui import (
27
+ INFO_BOX_STYLES,
28
+ TOOLTIP_STYLES,
29
+ create_logo,
30
+ create_page,
31
+ create_secure_html_response,
32
+ )
33
+
34
+ if TYPE_CHECKING:
35
+ from mcp.server.auth.provider import OAuthAuthorizationServerProvider
36
+
37
+ logger = get_logger(__name__)
38
+
39
+
40
+ def create_unregistered_client_html(
41
+ client_id: str,
42
+ registration_endpoint: str,
43
+ discovery_endpoint: str,
44
+ server_name: str | None = None,
45
+ server_icon_url: str | None = None,
46
+ title: str = "Client Not Registered",
47
+ ) -> str:
48
+ """Create styled HTML error page for unregistered client attempts.
49
+
50
+ Args:
51
+ client_id: The unregistered client ID that was provided
52
+ registration_endpoint: URL of the registration endpoint
53
+ discovery_endpoint: URL of the OAuth metadata discovery endpoint
54
+ server_name: Optional server name for branding
55
+ server_icon_url: Optional server icon URL
56
+ title: Page title
57
+
58
+ Returns:
59
+ HTML string for the error page
60
+ """
61
+ import html as html_module
62
+
63
+ client_id_escaped = html_module.escape(client_id)
64
+
65
+ # Main error message
66
+ error_box = f"""
67
+ <div class="info-box error">
68
+ <p>The client ID <code>{client_id_escaped}</code> was not found in the server's client registry.</p>
69
+ </div>
70
+ """
71
+
72
+ # What to do - yellow warning box
73
+ warning_box = """
74
+ <div class="info-box warning">
75
+ <p>Your MCP client opened this page to complete OAuth authorization,
76
+ but the server did not recognize its client ID. To fix this:</p>
77
+ <ul>
78
+ <li>Close this browser window</li>
79
+ <li>Clear authentication tokens in your MCP client (or restart it)</li>
80
+ <li>Try connecting again - your client should automatically re-register</li>
81
+ </ul>
82
+ </div>
83
+ """
84
+
85
+ # Help link with tooltip (similar to consent screen)
86
+ help_link = """
87
+ <div class="help-link-container">
88
+ <span class="help-link">
89
+ Why am I seeing this?
90
+ <span class="tooltip">
91
+ OAuth 2.0 requires clients to register before authorization.
92
+ This server returned a 400 error because the provided client
93
+ ID was not found.
94
+ <br><br>
95
+ In browser-delegated OAuth flows, your application cannot
96
+ detect this error automatically; it's waiting for a
97
+ callback that will never arrive. You must manually clear
98
+ auth tokens and reconnect.
99
+ </span>
100
+ </span>
101
+ </div>
102
+ """
103
+
104
+ # Build page content
105
+ content = f"""
106
+ <div class="container">
107
+ {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}
108
+ <h1>{title}</h1>
109
+ {error_box}
110
+ {warning_box}
111
+ </div>
112
+ {help_link}
113
+ """
114
+
115
+ # Use same styles as consent page
116
+ additional_styles = (
117
+ INFO_BOX_STYLES
118
+ + TOOLTIP_STYLES
119
+ + """
120
+ /* Error variant for info-box */
121
+ .info-box.error {
122
+ background: #fef2f2;
123
+ border-color: #f87171;
124
+ }
125
+ .info-box.error strong {
126
+ color: #991b1b;
127
+ }
128
+ /* Warning variant for info-box (yellow) */
129
+ .info-box.warning {
130
+ background: #fffbeb;
131
+ border-color: #fbbf24;
132
+ }
133
+ .info-box.warning strong {
134
+ color: #92400e;
135
+ }
136
+ .info-box code {
137
+ background: rgba(0, 0, 0, 0.05);
138
+ padding: 2px 6px;
139
+ border-radius: 3px;
140
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
141
+ font-size: 0.9em;
142
+ }
143
+ .info-box ul {
144
+ margin: 10px 0;
145
+ padding-left: 20px;
146
+ }
147
+ .info-box li {
148
+ margin: 6px 0;
149
+ }
150
+ """
151
+ )
152
+
153
+ return create_page(
154
+ content=content,
155
+ title=title,
156
+ additional_styles=additional_styles,
157
+ )
158
+
159
+
160
+ class AuthorizationHandler(SDKAuthorizationHandler):
161
+ """Authorization handler with enhanced error responses for unregistered clients.
162
+
163
+ This handler extends the MCP SDK's AuthorizationHandler to provide better UX
164
+ when clients attempt to authorize without being registered. It implements
165
+ content negotiation to return:
166
+
167
+ - HTML error pages for browser requests
168
+ - Enhanced JSON with registration hints for API clients
169
+ - Link headers pointing to registration endpoints
170
+
171
+ This maintains OAuth 2.1 compliance (returns 400 for invalid client_id)
172
+ while providing actionable guidance to fix the error.
173
+ """
174
+
175
+ def __init__(
176
+ self,
177
+ provider: OAuthAuthorizationServerProvider,
178
+ base_url: AnyHttpUrl | str,
179
+ server_name: str | None = None,
180
+ server_icon_url: str | None = None,
181
+ ):
182
+ """Initialize the enhanced authorization handler.
183
+
184
+ Args:
185
+ provider: OAuth authorization server provider
186
+ base_url: Base URL of the server for constructing endpoint URLs
187
+ server_name: Optional server name for branding
188
+ server_icon_url: Optional server icon URL for branding
189
+ """
190
+ super().__init__(provider)
191
+ self._base_url = str(base_url).rstrip("/")
192
+ self._server_name = server_name
193
+ self._server_icon_url = server_icon_url
194
+
195
+ async def handle(self, request: Request) -> Response:
196
+ """Handle authorization request with enhanced error responses.
197
+
198
+ This method extends the SDK's authorization handler and intercepts
199
+ errors for unregistered clients to provide better error responses
200
+ based on the client's Accept header.
201
+
202
+ Args:
203
+ request: The authorization request
204
+
205
+ Returns:
206
+ Response (redirect on success, error response on failure)
207
+ """
208
+ # Call the SDK handler
209
+ response = await super().handle(request)
210
+
211
+ # Check if this is a client not found error
212
+ if response.status_code == 400:
213
+ # Try to extract client_id from request for enhanced error
214
+ client_id = None
215
+ if request.method == "GET":
216
+ client_id = request.query_params.get("client_id")
217
+ else:
218
+ form = await request.form()
219
+ client_id = form.get("client_id")
220
+
221
+ # If we have a client_id and the error is about it not being found,
222
+ # enhance the response
223
+ if client_id:
224
+ try:
225
+ # Check if response body contains "not found" error
226
+ if hasattr(response, "body"):
227
+ import json
228
+
229
+ body = json.loads(response.body)
230
+ if (
231
+ body.get("error") == "invalid_request"
232
+ and "not found" in body.get("error_description", "").lower()
233
+ ):
234
+ return await self._create_enhanced_error_response(
235
+ request, client_id, body.get("state")
236
+ )
237
+ except Exception:
238
+ # If we can't parse the response, just return the original
239
+ pass
240
+
241
+ return response
242
+
243
+ async def _create_enhanced_error_response(
244
+ self, request: Request, client_id: str, state: str | None
245
+ ) -> Response:
246
+ """Create enhanced error response with content negotiation.
247
+
248
+ Args:
249
+ request: The original request
250
+ client_id: The unregistered client ID
251
+ state: The state parameter from the request
252
+
253
+ Returns:
254
+ HTML or JSON error response based on Accept header
255
+ """
256
+ registration_endpoint = f"{self._base_url}/register"
257
+ discovery_endpoint = f"{self._base_url}/.well-known/oauth-authorization-server"
258
+
259
+ # Extract server metadata from app state (same pattern as consent screen)
260
+ from fastmcp.server.server import FastMCP
261
+
262
+ fastmcp = getattr(request.app.state, "fastmcp_server", None)
263
+
264
+ if isinstance(fastmcp, FastMCP):
265
+ server_name = fastmcp.name
266
+ icons = fastmcp.icons
267
+ server_icon_url = icons[0].src if icons else None
268
+ else:
269
+ server_name = self._server_name
270
+ server_icon_url = self._server_icon_url
271
+
272
+ # Check Accept header for content negotiation
273
+ accept = request.headers.get("accept", "")
274
+
275
+ # Prefer HTML for browsers
276
+ if "text/html" in accept:
277
+ html = create_unregistered_client_html(
278
+ client_id=client_id,
279
+ registration_endpoint=registration_endpoint,
280
+ discovery_endpoint=discovery_endpoint,
281
+ server_name=server_name,
282
+ server_icon_url=server_icon_url,
283
+ )
284
+ response = create_secure_html_response(html, status_code=400)
285
+ else:
286
+ # Return enhanced JSON for API clients
287
+ from mcp.server.auth.handlers.authorize import AuthorizationErrorResponse
288
+
289
+ error_data = AuthorizationErrorResponse(
290
+ error="invalid_request",
291
+ error_description=(
292
+ f"Client ID '{client_id}' is not registered with this server. "
293
+ f"MCP clients should automatically re-register by sending a POST request to "
294
+ f"the registration_endpoint and retry authorization. "
295
+ f"If this persists, clear cached authentication tokens and reconnect."
296
+ ),
297
+ state=state,
298
+ )
299
+
300
+ # Add extra fields to help clients discover registration
301
+ error_dict = error_data.model_dump(exclude_none=True)
302
+ error_dict["registration_endpoint"] = registration_endpoint
303
+ error_dict["authorization_server_metadata"] = discovery_endpoint
304
+
305
+ from starlette.responses import JSONResponse
306
+
307
+ response = JSONResponse(
308
+ status_code=400,
309
+ content=error_dict,
310
+ headers={"Cache-Control": "no-store"},
311
+ )
312
+
313
+ # Add Link header for registration endpoint discovery
314
+ response.headers["Link"] = (
315
+ f'<{registration_endpoint}>; rel="http://oauth.net/core/2.1/#registration"'
316
+ )
317
+
318
+ logger.info(
319
+ "Unregistered client_id=%s, returned %s error response",
320
+ client_id,
321
+ "HTML" if "text/html" in accept else "JSON",
322
+ )
323
+
324
+ return response