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.
- fastmcp/__init__.py +2 -2
- fastmcp/cli/cli.py +3 -2
- fastmcp/cli/install/claude_code.py +3 -3
- fastmcp/client/__init__.py +9 -9
- fastmcp/client/auth/oauth.py +7 -6
- fastmcp/client/client.py +10 -10
- fastmcp/client/oauth_callback.py +6 -2
- fastmcp/client/sampling.py +1 -1
- fastmcp/client/transports.py +35 -34
- fastmcp/contrib/component_manager/__init__.py +1 -1
- fastmcp/contrib/component_manager/component_manager.py +2 -2
- fastmcp/contrib/mcp_mixin/__init__.py +2 -2
- fastmcp/experimental/sampling/handlers/openai.py +2 -2
- fastmcp/experimental/server/openapi/__init__.py +5 -8
- fastmcp/experimental/server/openapi/components.py +11 -7
- fastmcp/experimental/server/openapi/routing.py +2 -2
- fastmcp/experimental/utilities/openapi/__init__.py +10 -15
- fastmcp/experimental/utilities/openapi/director.py +1 -1
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +2 -2
- fastmcp/experimental/utilities/openapi/models.py +3 -3
- fastmcp/experimental/utilities/openapi/parser.py +3 -5
- fastmcp/experimental/utilities/openapi/schemas.py +2 -2
- fastmcp/mcp_config.py +2 -3
- fastmcp/prompts/__init__.py +1 -1
- fastmcp/prompts/prompt.py +9 -13
- fastmcp/resources/__init__.py +5 -5
- fastmcp/resources/resource.py +1 -3
- fastmcp/resources/resource_manager.py +1 -1
- fastmcp/resources/types.py +30 -24
- fastmcp/server/__init__.py +1 -1
- fastmcp/server/auth/__init__.py +5 -5
- fastmcp/server/auth/auth.py +2 -2
- fastmcp/server/auth/handlers/authorize.py +324 -0
- fastmcp/server/auth/jwt_issuer.py +39 -92
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +236 -217
- fastmcp/server/auth/oidc_proxy.py +18 -3
- fastmcp/server/auth/providers/auth0.py +28 -15
- fastmcp/server/auth/providers/aws.py +16 -1
- fastmcp/server/auth/providers/azure.py +101 -40
- fastmcp/server/auth/providers/bearer.py +1 -1
- fastmcp/server/auth/providers/github.py +16 -1
- fastmcp/server/auth/providers/google.py +16 -1
- fastmcp/server/auth/providers/in_memory.py +2 -2
- fastmcp/server/auth/providers/introspection.py +2 -2
- fastmcp/server/auth/providers/jwt.py +17 -18
- fastmcp/server/auth/providers/supabase.py +1 -1
- fastmcp/server/auth/providers/workos.py +18 -3
- fastmcp/server/context.py +41 -12
- fastmcp/server/dependencies.py +5 -6
- fastmcp/server/elicitation.py +1 -1
- fastmcp/server/http.py +3 -4
- fastmcp/server/middleware/__init__.py +1 -1
- fastmcp/server/middleware/caching.py +1 -1
- fastmcp/server/middleware/error_handling.py +8 -8
- fastmcp/server/middleware/middleware.py +1 -1
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/openapi.py +10 -6
- fastmcp/server/proxy.py +5 -4
- fastmcp/server/server.py +74 -55
- fastmcp/settings.py +2 -1
- fastmcp/tools/__init__.py +1 -1
- fastmcp/tools/tool.py +12 -12
- fastmcp/tools/tool_manager.py +8 -4
- fastmcp/tools/tool_transform.py +6 -6
- fastmcp/utilities/cli.py +50 -21
- fastmcp/utilities/inspect.py +2 -2
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/logging.py +14 -18
- 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/sources/base.py +0 -1
- fastmcp/utilities/openapi.py +9 -9
- fastmcp/utilities/tests.py +2 -4
- fastmcp/utilities/ui.py +126 -6
- {fastmcp-2.13.0rc2.dist-info → fastmcp-2.13.0.1.dist-info}/METADATA +5 -5
- fastmcp-2.13.0.1.dist-info/RECORD +141 -0
- fastmcp-2.13.0rc2.dist-info/RECORD +0 -138
- {fastmcp-2.13.0rc2.dist-info → fastmcp-2.13.0.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.13.0rc2.dist-info → fastmcp-2.13.0.1.dist-info}/entry_points.txt +0 -0
- {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(
|
|
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
|
fastmcp/resources/__init__.py
CHANGED
|
@@ -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
|
-
"
|
|
14
|
+
"DirectoryResource",
|
|
17
15
|
"FileResource",
|
|
16
|
+
"FunctionResource",
|
|
18
17
|
"HttpResource",
|
|
19
|
-
"
|
|
20
|
-
"ResourceTemplate",
|
|
18
|
+
"Resource",
|
|
21
19
|
"ResourceManager",
|
|
20
|
+
"ResourceTemplate",
|
|
21
|
+
"TextResource",
|
|
22
22
|
]
|
fastmcp/resources/resource.py
CHANGED
|
@@ -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
|
|
238
|
+
for template_key in templates:
|
|
239
239
|
if match_uri_template(uri_str, template_key):
|
|
240
240
|
return True
|
|
241
241
|
|
fastmcp/resources/types.py
CHANGED
|
@@ -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
|
|
79
|
-
return await
|
|
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.
|
|
134
|
+
if not await self._async_path.exists():
|
|
125
135
|
raise FileNotFoundError(f"Directory not found: {self.path}")
|
|
126
|
-
if not self.
|
|
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
|
|
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}
|
|
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
|
|
148
|
-
|
|
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
|
fastmcp/server/__init__.py
CHANGED
fastmcp/server/auth/__init__.py
CHANGED
|
@@ -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
|
-
"
|
|
18
|
-
"RemoteAuthProvider",
|
|
19
|
-
"AccessToken",
|
|
16
|
+
"OAuthProvider",
|
|
20
17
|
"OAuthProxy",
|
|
18
|
+
"RemoteAuthProvider",
|
|
19
|
+
"StaticTokenVerifier",
|
|
20
|
+
"TokenVerifier",
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
|
fastmcp/server/auth/auth.py
CHANGED
|
@@ -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
|