fastmcp 2.14.4__py3-none-any.whl → 3.0.0b1__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/_vendor/__init__.py +1 -0
- fastmcp/_vendor/docket_di/README.md +7 -0
- fastmcp/_vendor/docket_di/__init__.py +163 -0
- fastmcp/cli/cli.py +112 -28
- fastmcp/cli/install/claude_code.py +1 -5
- fastmcp/cli/install/claude_desktop.py +1 -5
- fastmcp/cli/install/cursor.py +1 -5
- fastmcp/cli/install/gemini_cli.py +1 -5
- fastmcp/cli/install/mcp_json.py +1 -6
- fastmcp/cli/run.py +146 -5
- fastmcp/client/__init__.py +7 -9
- fastmcp/client/auth/oauth.py +18 -17
- fastmcp/client/client.py +100 -870
- fastmcp/client/elicitation.py +1 -1
- fastmcp/client/mixins/__init__.py +13 -0
- fastmcp/client/mixins/prompts.py +295 -0
- fastmcp/client/mixins/resources.py +325 -0
- fastmcp/client/mixins/task_management.py +157 -0
- fastmcp/client/mixins/tools.py +397 -0
- fastmcp/client/sampling/handlers/anthropic.py +2 -2
- fastmcp/client/sampling/handlers/openai.py +1 -1
- fastmcp/client/tasks.py +3 -3
- fastmcp/client/telemetry.py +47 -0
- fastmcp/client/transports/__init__.py +38 -0
- fastmcp/client/transports/base.py +82 -0
- fastmcp/client/transports/config.py +170 -0
- fastmcp/client/transports/http.py +145 -0
- fastmcp/client/transports/inference.py +154 -0
- fastmcp/client/transports/memory.py +90 -0
- fastmcp/client/transports/sse.py +89 -0
- fastmcp/client/transports/stdio.py +543 -0
- fastmcp/contrib/component_manager/README.md +4 -10
- fastmcp/contrib/component_manager/__init__.py +1 -2
- fastmcp/contrib/component_manager/component_manager.py +95 -160
- fastmcp/contrib/component_manager/example.py +1 -1
- fastmcp/contrib/mcp_mixin/example.py +4 -4
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +11 -4
- fastmcp/decorators.py +41 -0
- fastmcp/dependencies.py +12 -1
- fastmcp/exceptions.py +4 -0
- fastmcp/experimental/server/openapi/__init__.py +18 -15
- fastmcp/mcp_config.py +13 -4
- fastmcp/prompts/__init__.py +6 -3
- fastmcp/prompts/function_prompt.py +465 -0
- fastmcp/prompts/prompt.py +321 -271
- fastmcp/resources/__init__.py +5 -3
- fastmcp/resources/function_resource.py +335 -0
- fastmcp/resources/resource.py +325 -115
- fastmcp/resources/template.py +215 -43
- fastmcp/resources/types.py +27 -12
- fastmcp/server/__init__.py +2 -2
- fastmcp/server/auth/__init__.py +14 -0
- fastmcp/server/auth/auth.py +30 -10
- fastmcp/server/auth/authorization.py +190 -0
- fastmcp/server/auth/oauth_proxy/__init__.py +14 -0
- fastmcp/server/auth/oauth_proxy/consent.py +361 -0
- fastmcp/server/auth/oauth_proxy/models.py +178 -0
- fastmcp/server/auth/{oauth_proxy.py → oauth_proxy/proxy.py} +24 -778
- fastmcp/server/auth/oauth_proxy/ui.py +277 -0
- fastmcp/server/auth/oidc_proxy.py +2 -2
- fastmcp/server/auth/providers/auth0.py +24 -94
- fastmcp/server/auth/providers/aws.py +26 -95
- fastmcp/server/auth/providers/azure.py +41 -129
- fastmcp/server/auth/providers/descope.py +18 -49
- fastmcp/server/auth/providers/discord.py +25 -86
- fastmcp/server/auth/providers/github.py +23 -87
- fastmcp/server/auth/providers/google.py +24 -87
- fastmcp/server/auth/providers/introspection.py +60 -79
- fastmcp/server/auth/providers/jwt.py +30 -67
- fastmcp/server/auth/providers/oci.py +47 -110
- fastmcp/server/auth/providers/scalekit.py +23 -61
- fastmcp/server/auth/providers/supabase.py +18 -47
- fastmcp/server/auth/providers/workos.py +34 -127
- fastmcp/server/context.py +372 -419
- fastmcp/server/dependencies.py +541 -251
- fastmcp/server/elicitation.py +20 -18
- fastmcp/server/event_store.py +3 -3
- fastmcp/server/http.py +16 -6
- fastmcp/server/lifespan.py +198 -0
- fastmcp/server/low_level.py +92 -2
- fastmcp/server/middleware/__init__.py +5 -1
- fastmcp/server/middleware/authorization.py +312 -0
- fastmcp/server/middleware/caching.py +101 -54
- fastmcp/server/middleware/middleware.py +6 -9
- fastmcp/server/middleware/ping.py +70 -0
- fastmcp/server/middleware/tool_injection.py +2 -2
- fastmcp/server/mixins/__init__.py +7 -0
- fastmcp/server/mixins/lifespan.py +217 -0
- fastmcp/server/mixins/mcp_operations.py +392 -0
- fastmcp/server/mixins/transport.py +342 -0
- fastmcp/server/openapi/__init__.py +41 -21
- fastmcp/server/openapi/components.py +16 -339
- fastmcp/server/openapi/routing.py +34 -118
- fastmcp/server/openapi/server.py +67 -392
- fastmcp/server/providers/__init__.py +71 -0
- fastmcp/server/providers/aggregate.py +261 -0
- fastmcp/server/providers/base.py +578 -0
- fastmcp/server/providers/fastmcp_provider.py +674 -0
- fastmcp/server/providers/filesystem.py +226 -0
- fastmcp/server/providers/filesystem_discovery.py +327 -0
- fastmcp/server/providers/local_provider/__init__.py +11 -0
- fastmcp/server/providers/local_provider/decorators/__init__.py +15 -0
- fastmcp/server/providers/local_provider/decorators/prompts.py +256 -0
- fastmcp/server/providers/local_provider/decorators/resources.py +240 -0
- fastmcp/server/providers/local_provider/decorators/tools.py +315 -0
- fastmcp/server/providers/local_provider/local_provider.py +465 -0
- fastmcp/server/providers/openapi/__init__.py +39 -0
- fastmcp/server/providers/openapi/components.py +332 -0
- fastmcp/server/providers/openapi/provider.py +405 -0
- fastmcp/server/providers/openapi/routing.py +109 -0
- fastmcp/server/providers/proxy.py +867 -0
- fastmcp/server/providers/skills/__init__.py +59 -0
- fastmcp/server/providers/skills/_common.py +101 -0
- fastmcp/server/providers/skills/claude_provider.py +44 -0
- fastmcp/server/providers/skills/directory_provider.py +153 -0
- fastmcp/server/providers/skills/skill_provider.py +432 -0
- fastmcp/server/providers/skills/vendor_providers.py +142 -0
- fastmcp/server/providers/wrapped_provider.py +140 -0
- fastmcp/server/proxy.py +34 -700
- fastmcp/server/sampling/run.py +341 -2
- fastmcp/server/sampling/sampling_tool.py +4 -3
- fastmcp/server/server.py +1214 -2171
- fastmcp/server/tasks/__init__.py +2 -1
- fastmcp/server/tasks/capabilities.py +13 -1
- fastmcp/server/tasks/config.py +66 -3
- fastmcp/server/tasks/handlers.py +65 -273
- fastmcp/server/tasks/keys.py +4 -6
- fastmcp/server/tasks/requests.py +474 -0
- fastmcp/server/tasks/routing.py +76 -0
- fastmcp/server/tasks/subscriptions.py +20 -11
- fastmcp/server/telemetry.py +131 -0
- fastmcp/server/transforms/__init__.py +244 -0
- fastmcp/server/transforms/namespace.py +193 -0
- fastmcp/server/transforms/prompts_as_tools.py +175 -0
- fastmcp/server/transforms/resources_as_tools.py +190 -0
- fastmcp/server/transforms/tool_transform.py +96 -0
- fastmcp/server/transforms/version_filter.py +124 -0
- fastmcp/server/transforms/visibility.py +526 -0
- fastmcp/settings.py +34 -96
- fastmcp/telemetry.py +122 -0
- fastmcp/tools/__init__.py +10 -3
- fastmcp/tools/function_parsing.py +201 -0
- fastmcp/tools/function_tool.py +467 -0
- fastmcp/tools/tool.py +215 -362
- fastmcp/tools/tool_transform.py +38 -21
- fastmcp/utilities/async_utils.py +69 -0
- fastmcp/utilities/components.py +152 -91
- fastmcp/utilities/inspect.py +8 -20
- fastmcp/utilities/json_schema.py +12 -5
- fastmcp/utilities/json_schema_type.py +17 -15
- fastmcp/utilities/lifespan.py +56 -0
- fastmcp/utilities/logging.py +12 -4
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
- fastmcp/utilities/openapi/parser.py +3 -3
- fastmcp/utilities/pagination.py +80 -0
- fastmcp/utilities/skills.py +253 -0
- fastmcp/utilities/tests.py +0 -16
- fastmcp/utilities/timeout.py +47 -0
- fastmcp/utilities/types.py +1 -1
- fastmcp/utilities/versions.py +285 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/METADATA +8 -5
- fastmcp-3.0.0b1.dist-info/RECORD +228 -0
- fastmcp/client/transports.py +0 -1170
- fastmcp/contrib/component_manager/component_service.py +0 -209
- fastmcp/prompts/prompt_manager.py +0 -117
- fastmcp/resources/resource_manager.py +0 -338
- fastmcp/server/tasks/converters.py +0 -206
- fastmcp/server/tasks/protocol.py +0 -359
- fastmcp/tools/tool_manager.py +0 -170
- fastmcp/utilities/mcp_config.py +0 -56
- fastmcp-2.14.4.dist-info/RECORD +0 -161
- /fastmcp/server/{openapi → providers/openapi}/README.md +0 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/WHEEL +0 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
|
@@ -18,15 +18,12 @@ production use with enterprise identity providers.
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
-
import base64
|
|
22
21
|
import hashlib
|
|
23
|
-
import hmac
|
|
24
|
-
import json
|
|
25
22
|
import secrets
|
|
26
23
|
import time
|
|
27
24
|
from base64 import urlsafe_b64encode
|
|
28
|
-
from typing import
|
|
29
|
-
from urllib.parse import urlencode
|
|
25
|
+
from typing import Any
|
|
26
|
+
from urllib.parse import urlencode
|
|
30
27
|
|
|
31
28
|
import httpx
|
|
32
29
|
from authlib.common.security import generate_token
|
|
@@ -48,7 +45,7 @@ from mcp.server.auth.settings import (
|
|
|
48
45
|
RevocationOptions,
|
|
49
46
|
)
|
|
50
47
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
|
51
|
-
from pydantic import AnyHttpUrl, AnyUrl,
|
|
48
|
+
from pydantic import AnyHttpUrl, AnyUrl, SecretStr
|
|
52
49
|
from starlette.requests import Request
|
|
53
50
|
from starlette.responses import HTMLResponse, RedirectResponse
|
|
54
51
|
from starlette.routing import Route
|
|
@@ -61,457 +58,27 @@ from fastmcp.server.auth.jwt_issuer import (
|
|
|
61
58
|
JWTIssuer,
|
|
62
59
|
derive_jwt_key,
|
|
63
60
|
)
|
|
64
|
-
from fastmcp.server.auth.
|
|
65
|
-
|
|
61
|
+
from fastmcp.server.auth.oauth_proxy.consent import ConsentMixin
|
|
62
|
+
from fastmcp.server.auth.oauth_proxy.models import (
|
|
63
|
+
DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS,
|
|
64
|
+
DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
|
|
65
|
+
DEFAULT_AUTH_CODE_EXPIRY_SECONDS,
|
|
66
|
+
HTTP_TIMEOUT_SECONDS,
|
|
67
|
+
ClientCode,
|
|
68
|
+
JTIMapping,
|
|
69
|
+
OAuthTransaction,
|
|
70
|
+
ProxyDCRClient,
|
|
71
|
+
RefreshTokenMetadata,
|
|
72
|
+
UpstreamTokenSet,
|
|
73
|
+
_hash_token,
|
|
66
74
|
)
|
|
75
|
+
from fastmcp.server.auth.oauth_proxy.ui import create_error_html
|
|
67
76
|
from fastmcp.utilities.logging import get_logger
|
|
68
|
-
from fastmcp.utilities.ui import (
|
|
69
|
-
BUTTON_STYLES,
|
|
70
|
-
DETAIL_BOX_STYLES,
|
|
71
|
-
DETAILS_STYLES,
|
|
72
|
-
INFO_BOX_STYLES,
|
|
73
|
-
REDIRECT_SECTION_STYLES,
|
|
74
|
-
TOOLTIP_STYLES,
|
|
75
|
-
create_logo,
|
|
76
|
-
create_page,
|
|
77
|
-
create_secure_html_response,
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
if TYPE_CHECKING:
|
|
81
|
-
pass
|
|
82
77
|
|
|
83
78
|
logger = get_logger(__name__)
|
|
84
79
|
|
|
85
80
|
|
|
86
|
-
|
|
87
|
-
# Constants
|
|
88
|
-
# -------------------------------------------------------------------------
|
|
89
|
-
|
|
90
|
-
# Default token expiration times
|
|
91
|
-
DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: Final[int] = 60 * 60 # 1 hour
|
|
92
|
-
DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS: Final[int] = (
|
|
93
|
-
60 * 60 * 24 * 365
|
|
94
|
-
) # 1 year
|
|
95
|
-
DEFAULT_AUTH_CODE_EXPIRY_SECONDS: Final[int] = 5 * 60 # 5 minutes
|
|
96
|
-
|
|
97
|
-
# HTTP client timeout
|
|
98
|
-
HTTP_TIMEOUT_SECONDS: Final[int] = 30
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# -------------------------------------------------------------------------
|
|
102
|
-
# Pydantic Models
|
|
103
|
-
# -------------------------------------------------------------------------
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
class OAuthTransaction(BaseModel):
|
|
107
|
-
"""OAuth transaction state for consent flow.
|
|
108
|
-
|
|
109
|
-
Stored server-side to track active authorization flows with client context.
|
|
110
|
-
Includes CSRF tokens for consent protection per MCP security best practices.
|
|
111
|
-
"""
|
|
112
|
-
|
|
113
|
-
txn_id: str
|
|
114
|
-
client_id: str
|
|
115
|
-
client_redirect_uri: str
|
|
116
|
-
client_state: str
|
|
117
|
-
code_challenge: str | None
|
|
118
|
-
code_challenge_method: str
|
|
119
|
-
scopes: list[str]
|
|
120
|
-
created_at: float
|
|
121
|
-
resource: str | None = None
|
|
122
|
-
proxy_code_verifier: str | None = None
|
|
123
|
-
csrf_token: str | None = None
|
|
124
|
-
csrf_expires_at: float | None = None
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
class ClientCode(BaseModel):
|
|
128
|
-
"""Client authorization code with PKCE and upstream tokens.
|
|
129
|
-
|
|
130
|
-
Stored server-side after upstream IdP callback. Contains the upstream
|
|
131
|
-
tokens bound to the client's PKCE challenge for secure token exchange.
|
|
132
|
-
"""
|
|
133
|
-
|
|
134
|
-
code: str
|
|
135
|
-
client_id: str
|
|
136
|
-
redirect_uri: str
|
|
137
|
-
code_challenge: str | None
|
|
138
|
-
code_challenge_method: str
|
|
139
|
-
scopes: list[str]
|
|
140
|
-
idp_tokens: dict[str, Any]
|
|
141
|
-
expires_at: float
|
|
142
|
-
created_at: float
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
class UpstreamTokenSet(BaseModel):
|
|
146
|
-
"""Stored upstream OAuth tokens from identity provider.
|
|
147
|
-
|
|
148
|
-
These tokens are obtained from the upstream provider (Google, GitHub, etc.)
|
|
149
|
-
and stored in plaintext within this model. Encryption is handled transparently
|
|
150
|
-
at the storage layer via FernetEncryptionWrapper. Tokens are never exposed to MCP clients.
|
|
151
|
-
"""
|
|
152
|
-
|
|
153
|
-
upstream_token_id: str # Unique ID for this token set
|
|
154
|
-
access_token: str # Upstream access token
|
|
155
|
-
refresh_token: str | None # Upstream refresh token
|
|
156
|
-
refresh_token_expires_at: (
|
|
157
|
-
float | None
|
|
158
|
-
) # Unix timestamp when refresh token expires (if known)
|
|
159
|
-
expires_at: float # Unix timestamp when access token expires
|
|
160
|
-
token_type: str # Usually "Bearer"
|
|
161
|
-
scope: str # Space-separated scopes
|
|
162
|
-
client_id: str # MCP client this is bound to
|
|
163
|
-
created_at: float # Unix timestamp
|
|
164
|
-
raw_token_data: dict[str, Any] = Field(default_factory=dict) # Full token response
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
class JTIMapping(BaseModel):
|
|
168
|
-
"""Maps FastMCP token JTI to upstream token ID.
|
|
169
|
-
|
|
170
|
-
This allows stateless JWT validation while still being able to look up
|
|
171
|
-
the corresponding upstream token when tools need to access upstream APIs.
|
|
172
|
-
"""
|
|
173
|
-
|
|
174
|
-
jti: str # JWT ID from FastMCP-issued token
|
|
175
|
-
upstream_token_id: str # References UpstreamTokenSet
|
|
176
|
-
created_at: float # Unix timestamp
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
class RefreshTokenMetadata(BaseModel):
|
|
180
|
-
"""Metadata for a refresh token, stored keyed by token hash.
|
|
181
|
-
|
|
182
|
-
We store only metadata (not the token itself) for security - if storage
|
|
183
|
-
is compromised, attackers get hashes they can't reverse into usable tokens.
|
|
184
|
-
"""
|
|
185
|
-
|
|
186
|
-
client_id: str
|
|
187
|
-
scopes: list[str]
|
|
188
|
-
expires_at: int | None = None
|
|
189
|
-
created_at: float
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def _hash_token(token: str) -> str:
|
|
193
|
-
"""Hash a token for secure storage lookup.
|
|
194
|
-
|
|
195
|
-
Uses SHA-256 to create a one-way hash. The original token cannot be
|
|
196
|
-
recovered from the hash, providing defense in depth if storage is compromised.
|
|
197
|
-
"""
|
|
198
|
-
return hashlib.sha256(token.encode()).hexdigest()
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
class ProxyDCRClient(OAuthClientInformationFull):
|
|
202
|
-
"""Client for DCR proxy with configurable redirect URI validation.
|
|
203
|
-
|
|
204
|
-
This special client class is critical for the OAuth proxy to work correctly
|
|
205
|
-
with Dynamic Client Registration (DCR). Here's why it exists:
|
|
206
|
-
|
|
207
|
-
Problem:
|
|
208
|
-
--------
|
|
209
|
-
When MCP clients use OAuth, they dynamically register with random localhost
|
|
210
|
-
ports (e.g., http://localhost:55454/callback). The OAuth proxy needs to:
|
|
211
|
-
1. Accept these dynamic redirect URIs from clients based on configured patterns
|
|
212
|
-
2. Use its own fixed redirect URI with the upstream provider (Google, GitHub, etc.)
|
|
213
|
-
3. Forward the authorization code back to the client's dynamic URI
|
|
214
|
-
|
|
215
|
-
Solution:
|
|
216
|
-
---------
|
|
217
|
-
This class validates redirect URIs against configurable patterns,
|
|
218
|
-
while the proxy internally uses its own fixed redirect URI with the upstream
|
|
219
|
-
provider. This allows the flow to work even when clients reconnect with
|
|
220
|
-
different ports or when tokens are cached.
|
|
221
|
-
|
|
222
|
-
Without proper validation, clients could get "Redirect URI not registered" errors
|
|
223
|
-
when trying to authenticate with cached tokens, or security vulnerabilities could
|
|
224
|
-
arise from accepting arbitrary redirect URIs.
|
|
225
|
-
"""
|
|
226
|
-
|
|
227
|
-
allowed_redirect_uri_patterns: list[str] | None = Field(default=None)
|
|
228
|
-
client_name: str | None = Field(default=None)
|
|
229
|
-
|
|
230
|
-
def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
|
|
231
|
-
"""Validate redirect URI against allowed patterns.
|
|
232
|
-
|
|
233
|
-
Since we're acting as a proxy and clients register dynamically,
|
|
234
|
-
we validate their redirect URIs against configurable patterns.
|
|
235
|
-
This is essential for cached token scenarios where the client may
|
|
236
|
-
reconnect with a different port.
|
|
237
|
-
"""
|
|
238
|
-
if redirect_uri is not None:
|
|
239
|
-
# Validate against allowed patterns
|
|
240
|
-
if validate_redirect_uri(
|
|
241
|
-
redirect_uri=redirect_uri,
|
|
242
|
-
allowed_patterns=self.allowed_redirect_uri_patterns,
|
|
243
|
-
):
|
|
244
|
-
return redirect_uri
|
|
245
|
-
# Fall back to normal validation if not in allowed patterns
|
|
246
|
-
return super().validate_redirect_uri(redirect_uri)
|
|
247
|
-
# If no redirect_uri provided, use default behavior
|
|
248
|
-
return super().validate_redirect_uri(redirect_uri)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
# -------------------------------------------------------------------------
|
|
252
|
-
# Helper Functions
|
|
253
|
-
# -------------------------------------------------------------------------
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
def create_consent_html(
|
|
257
|
-
client_id: str,
|
|
258
|
-
redirect_uri: str,
|
|
259
|
-
scopes: list[str],
|
|
260
|
-
txn_id: str,
|
|
261
|
-
csrf_token: str,
|
|
262
|
-
client_name: str | None = None,
|
|
263
|
-
title: str = "Application Access Request",
|
|
264
|
-
server_name: str | None = None,
|
|
265
|
-
server_icon_url: str | None = None,
|
|
266
|
-
server_website_url: str | None = None,
|
|
267
|
-
client_website_url: str | None = None,
|
|
268
|
-
csp_policy: str | None = None,
|
|
269
|
-
) -> str:
|
|
270
|
-
"""Create a styled HTML consent page for OAuth authorization requests.
|
|
271
|
-
|
|
272
|
-
Args:
|
|
273
|
-
csp_policy: Content Security Policy override.
|
|
274
|
-
If None, uses the built-in CSP policy with appropriate directives.
|
|
275
|
-
If empty string "", disables CSP entirely (no meta tag is rendered).
|
|
276
|
-
If a non-empty string, uses that as the CSP policy value.
|
|
277
|
-
"""
|
|
278
|
-
import html as html_module
|
|
279
|
-
|
|
280
|
-
client_display = html_module.escape(client_name or client_id)
|
|
281
|
-
server_name_escaped = html_module.escape(server_name or "FastMCP")
|
|
282
|
-
|
|
283
|
-
# Make server name a hyperlink if website URL is available
|
|
284
|
-
if server_website_url:
|
|
285
|
-
website_url_escaped = html_module.escape(server_website_url)
|
|
286
|
-
server_display = f'<a href="{website_url_escaped}" target="_blank" rel="noopener noreferrer" class="server-name-link">{server_name_escaped}</a>'
|
|
287
|
-
else:
|
|
288
|
-
server_display = server_name_escaped
|
|
289
|
-
|
|
290
|
-
# Build intro box with call-to-action
|
|
291
|
-
intro_box = f"""
|
|
292
|
-
<div class="info-box">
|
|
293
|
-
<p>The application <strong>{client_display}</strong> wants to access the MCP server <strong>{server_display}</strong>. Please ensure you recognize the callback address below.</p>
|
|
294
|
-
</div>
|
|
295
|
-
"""
|
|
296
|
-
|
|
297
|
-
# Build redirect URI section (yellow box, centered)
|
|
298
|
-
redirect_uri_escaped = html_module.escape(redirect_uri)
|
|
299
|
-
redirect_section = f"""
|
|
300
|
-
<div class="redirect-section">
|
|
301
|
-
<span class="label">Credentials will be sent to:</span>
|
|
302
|
-
<div class="value">{redirect_uri_escaped}</div>
|
|
303
|
-
</div>
|
|
304
|
-
"""
|
|
305
|
-
|
|
306
|
-
# Build advanced details with collapsible section
|
|
307
|
-
detail_rows = [
|
|
308
|
-
("Application Name", html_module.escape(client_name or client_id)),
|
|
309
|
-
("Application Website", html_module.escape(client_website_url or "N/A")),
|
|
310
|
-
("Application ID", client_id),
|
|
311
|
-
("Redirect URI", redirect_uri_escaped),
|
|
312
|
-
(
|
|
313
|
-
"Requested Scopes",
|
|
314
|
-
", ".join(html_module.escape(s) for s in scopes) if scopes else "None",
|
|
315
|
-
),
|
|
316
|
-
]
|
|
317
|
-
|
|
318
|
-
detail_rows_html = "\n".join(
|
|
319
|
-
[
|
|
320
|
-
f"""
|
|
321
|
-
<div class="detail-row">
|
|
322
|
-
<div class="detail-label">{label}:</div>
|
|
323
|
-
<div class="detail-value">{value}</div>
|
|
324
|
-
</div>
|
|
325
|
-
"""
|
|
326
|
-
for label, value in detail_rows
|
|
327
|
-
]
|
|
328
|
-
)
|
|
329
|
-
|
|
330
|
-
advanced_details = f"""
|
|
331
|
-
<details>
|
|
332
|
-
<summary>Advanced Details</summary>
|
|
333
|
-
<div class="detail-box">
|
|
334
|
-
{detail_rows_html}
|
|
335
|
-
</div>
|
|
336
|
-
</details>
|
|
337
|
-
"""
|
|
338
|
-
|
|
339
|
-
# Build form with buttons
|
|
340
|
-
# Use empty action to submit to current URL (/consent or /mcp/consent)
|
|
341
|
-
# The POST handler is registered at the same path as GET
|
|
342
|
-
form = f"""
|
|
343
|
-
<form id="consentForm" method="POST" action="">
|
|
344
|
-
<input type="hidden" name="txn_id" value="{txn_id}" />
|
|
345
|
-
<input type="hidden" name="csrf_token" value="{csrf_token}" />
|
|
346
|
-
<input type="hidden" name="submit" value="true" />
|
|
347
|
-
<div class="button-group">
|
|
348
|
-
<button type="submit" name="action" value="approve" class="btn-approve">Allow Access</button>
|
|
349
|
-
<button type="submit" name="action" value="deny" class="btn-deny">Deny</button>
|
|
350
|
-
</div>
|
|
351
|
-
</form>
|
|
352
|
-
"""
|
|
353
|
-
|
|
354
|
-
# Build help link with tooltip (identical to current implementation)
|
|
355
|
-
help_link = """
|
|
356
|
-
<div class="help-link-container">
|
|
357
|
-
<span class="help-link">
|
|
358
|
-
Why am I seeing this?
|
|
359
|
-
<span class="tooltip">
|
|
360
|
-
This FastMCP server requires your consent to allow a new client
|
|
361
|
-
to connect. This protects you from <a
|
|
362
|
-
href="https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#confused-deputy-problem"
|
|
363
|
-
target="_blank" class="tooltip-link">confused deputy
|
|
364
|
-
attacks</a>, where malicious clients could impersonate you
|
|
365
|
-
and steal access.<br><br>
|
|
366
|
-
<a
|
|
367
|
-
href="https://gofastmcp.com/servers/auth/oauth-proxy#confused-deputy-attacks"
|
|
368
|
-
target="_blank" class="tooltip-link">Learn more about
|
|
369
|
-
FastMCP security →</a>
|
|
370
|
-
</span>
|
|
371
|
-
</span>
|
|
372
|
-
</div>
|
|
373
|
-
"""
|
|
374
|
-
|
|
375
|
-
# Build the page content
|
|
376
|
-
content = f"""
|
|
377
|
-
<div class="container">
|
|
378
|
-
{create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}
|
|
379
|
-
<h1>Application Access Request</h1>
|
|
380
|
-
{intro_box}
|
|
381
|
-
{redirect_section}
|
|
382
|
-
{advanced_details}
|
|
383
|
-
{form}
|
|
384
|
-
</div>
|
|
385
|
-
{help_link}
|
|
386
|
-
"""
|
|
387
|
-
|
|
388
|
-
# Additional styles needed for this page
|
|
389
|
-
additional_styles = (
|
|
390
|
-
INFO_BOX_STYLES
|
|
391
|
-
+ REDIRECT_SECTION_STYLES
|
|
392
|
-
+ DETAILS_STYLES
|
|
393
|
-
+ DETAIL_BOX_STYLES
|
|
394
|
-
+ BUTTON_STYLES
|
|
395
|
-
+ TOOLTIP_STYLES
|
|
396
|
-
)
|
|
397
|
-
|
|
398
|
-
# Determine CSP policy to use
|
|
399
|
-
# If csp_policy is None, build the default CSP policy
|
|
400
|
-
# If csp_policy is empty string, CSP will be disabled entirely in create_page
|
|
401
|
-
# If csp_policy is a non-empty string, use it as-is
|
|
402
|
-
if csp_policy is None:
|
|
403
|
-
# Need to allow form-action for form submission
|
|
404
|
-
# Chrome requires explicit scheme declarations in CSP form-action when redirect chains
|
|
405
|
-
# end in custom protocol schemes (e.g., cursor://). Parse redirect_uri to include its scheme.
|
|
406
|
-
parsed_redirect = urlparse(redirect_uri)
|
|
407
|
-
redirect_scheme = parsed_redirect.scheme.lower()
|
|
408
|
-
|
|
409
|
-
# Build form-action directive with standard schemes plus custom protocol if present
|
|
410
|
-
form_action_schemes = ["https:", "http:"]
|
|
411
|
-
if redirect_scheme and redirect_scheme not in ("http", "https"):
|
|
412
|
-
# Custom protocol scheme (e.g., cursor:, vscode:, etc.)
|
|
413
|
-
form_action_schemes.append(f"{redirect_scheme}:")
|
|
414
|
-
|
|
415
|
-
form_action_directive = " ".join(form_action_schemes)
|
|
416
|
-
csp_policy = f"default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action {form_action_directive}"
|
|
417
|
-
|
|
418
|
-
return create_page(
|
|
419
|
-
content=content,
|
|
420
|
-
title=title,
|
|
421
|
-
additional_styles=additional_styles,
|
|
422
|
-
csp_policy=csp_policy,
|
|
423
|
-
)
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
def create_error_html(
|
|
427
|
-
error_title: str,
|
|
428
|
-
error_message: str,
|
|
429
|
-
error_details: dict[str, str] | None = None,
|
|
430
|
-
server_name: str | None = None,
|
|
431
|
-
server_icon_url: str | None = None,
|
|
432
|
-
) -> str:
|
|
433
|
-
"""Create a styled HTML error page for OAuth errors.
|
|
434
|
-
|
|
435
|
-
Args:
|
|
436
|
-
error_title: The error title (e.g., "OAuth Error", "Authorization Failed")
|
|
437
|
-
error_message: The main error message to display
|
|
438
|
-
error_details: Optional dictionary of error details to show (e.g., `{"Error Code": "invalid_client"}`)
|
|
439
|
-
server_name: Optional server name to display
|
|
440
|
-
server_icon_url: Optional URL to server icon/logo
|
|
441
|
-
|
|
442
|
-
Returns:
|
|
443
|
-
Complete HTML page as a string
|
|
444
|
-
"""
|
|
445
|
-
import html as html_module
|
|
446
|
-
|
|
447
|
-
error_message_escaped = html_module.escape(error_message)
|
|
448
|
-
|
|
449
|
-
# Build error message box
|
|
450
|
-
error_box = f"""
|
|
451
|
-
<div class="info-box error">
|
|
452
|
-
<p>{error_message_escaped}</p>
|
|
453
|
-
</div>
|
|
454
|
-
"""
|
|
455
|
-
|
|
456
|
-
# Build error details section if provided
|
|
457
|
-
details_section = ""
|
|
458
|
-
if error_details:
|
|
459
|
-
detail_rows_html = "\n".join(
|
|
460
|
-
[
|
|
461
|
-
f"""
|
|
462
|
-
<div class="detail-row">
|
|
463
|
-
<div class="detail-label">{html_module.escape(label)}:</div>
|
|
464
|
-
<div class="detail-value">{html_module.escape(value)}</div>
|
|
465
|
-
</div>
|
|
466
|
-
"""
|
|
467
|
-
for label, value in error_details.items()
|
|
468
|
-
]
|
|
469
|
-
)
|
|
470
|
-
|
|
471
|
-
details_section = f"""
|
|
472
|
-
<details>
|
|
473
|
-
<summary>Error Details</summary>
|
|
474
|
-
<div class="detail-box">
|
|
475
|
-
{detail_rows_html}
|
|
476
|
-
</div>
|
|
477
|
-
</details>
|
|
478
|
-
"""
|
|
479
|
-
|
|
480
|
-
# Build the page content
|
|
481
|
-
content = f"""
|
|
482
|
-
<div class="container">
|
|
483
|
-
{create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}
|
|
484
|
-
<h1>{html_module.escape(error_title)}</h1>
|
|
485
|
-
{error_box}
|
|
486
|
-
{details_section}
|
|
487
|
-
</div>
|
|
488
|
-
"""
|
|
489
|
-
|
|
490
|
-
# Additional styles needed for this page
|
|
491
|
-
# Override .info-box.error to use normal text color instead of red
|
|
492
|
-
additional_styles = (
|
|
493
|
-
INFO_BOX_STYLES
|
|
494
|
-
+ DETAILS_STYLES
|
|
495
|
-
+ DETAIL_BOX_STYLES
|
|
496
|
-
+ """
|
|
497
|
-
.info-box.error {
|
|
498
|
-
color: #111827;
|
|
499
|
-
}
|
|
500
|
-
"""
|
|
501
|
-
)
|
|
502
|
-
|
|
503
|
-
# Simple CSP policy for error pages (no forms needed)
|
|
504
|
-
csp_policy = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'"
|
|
505
|
-
|
|
506
|
-
return create_page(
|
|
507
|
-
content=content,
|
|
508
|
-
title=error_title,
|
|
509
|
-
additional_styles=additional_styles,
|
|
510
|
-
csp_policy=csp_policy,
|
|
511
|
-
)
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
class OAuthProxy(OAuthProvider):
|
|
81
|
+
class OAuthProxy(OAuthProvider, ConsentMixin):
|
|
515
82
|
"""OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs.
|
|
516
83
|
|
|
517
84
|
Purpose
|
|
@@ -674,8 +241,8 @@ class OAuthProxy(OAuthProvider):
|
|
|
674
241
|
service_documentation_url: Optional service documentation URL
|
|
675
242
|
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
|
676
243
|
Patterns support wildcards (e.g., "http://localhost:*", "https://*.example.com/*").
|
|
677
|
-
If None (default),
|
|
678
|
-
If empty list,
|
|
244
|
+
If None (default), all redirect URIs are allowed (for DCR compatibility).
|
|
245
|
+
If empty list, no redirect URIs are allowed.
|
|
679
246
|
These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app.
|
|
680
247
|
valid_scopes: List of all the possible valid scopes for a client.
|
|
681
248
|
These are advertised to clients through the `/.well-known` endpoints. Defaults to `required_scopes` if not provided.
|
|
@@ -1079,7 +646,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
1079
646
|
# Store transaction data for IdP callback processing
|
|
1080
647
|
if client.client_id is None:
|
|
1081
648
|
raise AuthorizeError(
|
|
1082
|
-
error="invalid_client", # type: ignore[arg-type]
|
|
649
|
+
error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type
|
|
1083
650
|
error_description="Client ID is required",
|
|
1084
651
|
)
|
|
1085
652
|
transaction = OAuthTransaction(
|
|
@@ -1162,7 +729,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
1162
729
|
# Create authorization code object with PKCE challenge
|
|
1163
730
|
if client.client_id is None:
|
|
1164
731
|
raise AuthorizeError(
|
|
1165
|
-
error="invalid_client", # type: ignore[arg-type]
|
|
732
|
+
error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type
|
|
1166
733
|
error_description="Client ID is required",
|
|
1167
734
|
)
|
|
1168
735
|
return AuthorizationCode(
|
|
@@ -1464,7 +1031,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
1464
1031
|
|
|
1465
1032
|
try:
|
|
1466
1033
|
logger.debug("Refreshing upstream token (jti=%s)", refresh_jti[:8])
|
|
1467
|
-
token_response: dict[str, Any] = await oauth_client.refresh_token(
|
|
1034
|
+
token_response: dict[str, Any] = await oauth_client.refresh_token(
|
|
1468
1035
|
url=self._upstream_token_endpoint,
|
|
1469
1036
|
refresh_token=upstream_token_set.refresh_token,
|
|
1470
1037
|
scope=" ".join(upstream_scopes) if upstream_scopes else None,
|
|
@@ -1890,7 +1457,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
1890
1457
|
|
|
1891
1458
|
idp_tokens: dict[str, Any] = await oauth_client.fetch_token(
|
|
1892
1459
|
**token_params
|
|
1893
|
-
)
|
|
1460
|
+
)
|
|
1894
1461
|
|
|
1895
1462
|
logger.debug(
|
|
1896
1463
|
f"Successfully exchanged IdP code for tokens (transaction: {txn_id}, PKCE: {bool(proxy_code_verifier)})"
|
|
@@ -1959,324 +1526,3 @@ class OAuthProxy(OAuthProvider):
|
|
|
1959
1526
|
error_message="Internal server error during OAuth callback processing. Please try again.",
|
|
1960
1527
|
)
|
|
1961
1528
|
return HTMLResponse(content=html_content, status_code=500)
|
|
1962
|
-
|
|
1963
|
-
# -------------------------------------------------------------------------
|
|
1964
|
-
# Consent Interstitial
|
|
1965
|
-
# -------------------------------------------------------------------------
|
|
1966
|
-
|
|
1967
|
-
def _normalize_uri(self, uri: str) -> str:
|
|
1968
|
-
"""Normalize a URI to a canonical form for consent tracking."""
|
|
1969
|
-
parsed = urlparse(uri)
|
|
1970
|
-
path = parsed.path or ""
|
|
1971
|
-
normalized = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{path}"
|
|
1972
|
-
if normalized.endswith("/") and len(path) > 1:
|
|
1973
|
-
normalized = normalized[:-1]
|
|
1974
|
-
return normalized
|
|
1975
|
-
|
|
1976
|
-
def _make_client_key(self, client_id: str, redirect_uri: str | AnyUrl) -> str:
|
|
1977
|
-
"""Create a stable key for consent tracking from client_id and redirect_uri."""
|
|
1978
|
-
normalized = self._normalize_uri(str(redirect_uri))
|
|
1979
|
-
return f"{client_id}:{normalized}"
|
|
1980
|
-
|
|
1981
|
-
def _cookie_name(self, base_name: str) -> str:
|
|
1982
|
-
"""Return secure cookie name for HTTPS, fallback for HTTP development."""
|
|
1983
|
-
if self._is_https:
|
|
1984
|
-
return f"__Host-{base_name}"
|
|
1985
|
-
return f"__{base_name}"
|
|
1986
|
-
|
|
1987
|
-
def _sign_cookie(self, payload: str) -> str:
|
|
1988
|
-
"""Sign a cookie payload with HMAC-SHA256.
|
|
1989
|
-
|
|
1990
|
-
Returns: base64(payload).base64(signature)
|
|
1991
|
-
"""
|
|
1992
|
-
# Use upstream client secret as signing key
|
|
1993
|
-
key = self._upstream_client_secret.get_secret_value().encode()
|
|
1994
|
-
signature = hmac.new(key, payload.encode(), hashlib.sha256).digest()
|
|
1995
|
-
signature_b64 = base64.b64encode(signature).decode()
|
|
1996
|
-
return f"{payload}.{signature_b64}"
|
|
1997
|
-
|
|
1998
|
-
def _verify_cookie(self, signed_value: str) -> str | None:
|
|
1999
|
-
"""Verify and extract payload from signed cookie.
|
|
2000
|
-
|
|
2001
|
-
Returns: payload if signature valid, None otherwise
|
|
2002
|
-
"""
|
|
2003
|
-
try:
|
|
2004
|
-
if "." not in signed_value:
|
|
2005
|
-
return None
|
|
2006
|
-
payload, signature_b64 = signed_value.rsplit(".", 1)
|
|
2007
|
-
|
|
2008
|
-
# Verify signature
|
|
2009
|
-
key = self._upstream_client_secret.get_secret_value().encode()
|
|
2010
|
-
expected_sig = hmac.new(key, payload.encode(), hashlib.sha256).digest()
|
|
2011
|
-
provided_sig = base64.b64decode(signature_b64.encode())
|
|
2012
|
-
|
|
2013
|
-
# Constant-time comparison
|
|
2014
|
-
if not hmac.compare_digest(expected_sig, provided_sig):
|
|
2015
|
-
return None
|
|
2016
|
-
|
|
2017
|
-
return payload
|
|
2018
|
-
except Exception:
|
|
2019
|
-
return None
|
|
2020
|
-
|
|
2021
|
-
def _decode_list_cookie(self, request: Request, base_name: str) -> list[str]:
|
|
2022
|
-
"""Decode and verify a signed base64-encoded JSON list from cookie. Returns [] if missing/invalid."""
|
|
2023
|
-
# Prefer secure name, but also check non-secure variant for dev
|
|
2024
|
-
secure_name = self._cookie_name(base_name)
|
|
2025
|
-
raw = request.cookies.get(secure_name) or request.cookies.get(f"__{base_name}")
|
|
2026
|
-
if not raw:
|
|
2027
|
-
return []
|
|
2028
|
-
try:
|
|
2029
|
-
# Verify signature
|
|
2030
|
-
payload = self._verify_cookie(raw)
|
|
2031
|
-
if not payload:
|
|
2032
|
-
logger.debug("Cookie signature verification failed for %s", secure_name)
|
|
2033
|
-
return []
|
|
2034
|
-
|
|
2035
|
-
# Decode payload
|
|
2036
|
-
data = base64.b64decode(payload.encode())
|
|
2037
|
-
value = json.loads(data.decode())
|
|
2038
|
-
if isinstance(value, list):
|
|
2039
|
-
return [str(x) for x in value]
|
|
2040
|
-
except Exception:
|
|
2041
|
-
logger.debug("Failed to decode cookie %s; treating as empty", secure_name)
|
|
2042
|
-
return []
|
|
2043
|
-
|
|
2044
|
-
def _encode_list_cookie(self, values: list[str]) -> str:
|
|
2045
|
-
"""Encode values to base64 and sign with HMAC.
|
|
2046
|
-
|
|
2047
|
-
Returns: signed cookie value (payload.signature)
|
|
2048
|
-
"""
|
|
2049
|
-
payload = json.dumps(values, separators=(",", ":")).encode()
|
|
2050
|
-
payload_b64 = base64.b64encode(payload).decode()
|
|
2051
|
-
return self._sign_cookie(payload_b64)
|
|
2052
|
-
|
|
2053
|
-
def _set_list_cookie(
|
|
2054
|
-
self,
|
|
2055
|
-
response: HTMLResponse | RedirectResponse,
|
|
2056
|
-
base_name: str,
|
|
2057
|
-
value_b64: str,
|
|
2058
|
-
max_age: int,
|
|
2059
|
-
) -> None:
|
|
2060
|
-
name = self._cookie_name(base_name)
|
|
2061
|
-
response.set_cookie(
|
|
2062
|
-
name,
|
|
2063
|
-
value_b64,
|
|
2064
|
-
max_age=max_age,
|
|
2065
|
-
secure=self._is_https,
|
|
2066
|
-
httponly=True,
|
|
2067
|
-
samesite="lax",
|
|
2068
|
-
path="/",
|
|
2069
|
-
)
|
|
2070
|
-
|
|
2071
|
-
def _build_upstream_authorize_url(
|
|
2072
|
-
self, txn_id: str, transaction: dict[str, Any]
|
|
2073
|
-
) -> str:
|
|
2074
|
-
"""Construct the upstream IdP authorization URL using stored transaction data."""
|
|
2075
|
-
query_params: dict[str, Any] = {
|
|
2076
|
-
"response_type": "code",
|
|
2077
|
-
"client_id": self._upstream_client_id,
|
|
2078
|
-
"redirect_uri": f"{str(self.base_url).rstrip('/')}{self._redirect_path}",
|
|
2079
|
-
"state": txn_id,
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
|
-
scopes_to_use = transaction.get("scopes") or self.required_scopes or []
|
|
2083
|
-
if scopes_to_use:
|
|
2084
|
-
query_params["scope"] = " ".join(scopes_to_use)
|
|
2085
|
-
|
|
2086
|
-
# If PKCE forwarding was enabled, include the proxy challenge
|
|
2087
|
-
proxy_code_verifier = transaction.get("proxy_code_verifier")
|
|
2088
|
-
if proxy_code_verifier:
|
|
2089
|
-
challenge_bytes = hashlib.sha256(proxy_code_verifier.encode()).digest()
|
|
2090
|
-
proxy_code_challenge = (
|
|
2091
|
-
urlsafe_b64encode(challenge_bytes).decode().rstrip("=")
|
|
2092
|
-
)
|
|
2093
|
-
query_params["code_challenge"] = proxy_code_challenge
|
|
2094
|
-
query_params["code_challenge_method"] = "S256"
|
|
2095
|
-
|
|
2096
|
-
# Forward resource indicator if present in transaction
|
|
2097
|
-
if resource := transaction.get("resource"):
|
|
2098
|
-
query_params["resource"] = resource
|
|
2099
|
-
|
|
2100
|
-
# Extra configured parameters
|
|
2101
|
-
if self._extra_authorize_params:
|
|
2102
|
-
query_params.update(self._extra_authorize_params)
|
|
2103
|
-
|
|
2104
|
-
separator = "&" if "?" in self._upstream_authorization_endpoint else "?"
|
|
2105
|
-
return f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}"
|
|
2106
|
-
|
|
2107
|
-
async def _handle_consent(
|
|
2108
|
-
self, request: Request
|
|
2109
|
-
) -> HTMLResponse | RedirectResponse:
|
|
2110
|
-
"""Handle consent page - dispatch to GET or POST handler based on method."""
|
|
2111
|
-
if request.method == "POST":
|
|
2112
|
-
return await self._submit_consent(request)
|
|
2113
|
-
return await self._show_consent_page(request)
|
|
2114
|
-
|
|
2115
|
-
async def _show_consent_page(
|
|
2116
|
-
self, request: Request
|
|
2117
|
-
) -> HTMLResponse | RedirectResponse:
|
|
2118
|
-
"""Display consent page or auto-approve/deny based on cookies."""
|
|
2119
|
-
from fastmcp.server.server import FastMCP
|
|
2120
|
-
|
|
2121
|
-
txn_id = request.query_params.get("txn_id")
|
|
2122
|
-
if not txn_id:
|
|
2123
|
-
return create_secure_html_response(
|
|
2124
|
-
"<h1>Error</h1><p>Invalid or expired transaction</p>", status_code=400
|
|
2125
|
-
)
|
|
2126
|
-
|
|
2127
|
-
txn_model = await self._transaction_store.get(key=txn_id)
|
|
2128
|
-
if not txn_model:
|
|
2129
|
-
return create_secure_html_response(
|
|
2130
|
-
"<h1>Error</h1><p>Invalid or expired transaction</p>", status_code=400
|
|
2131
|
-
)
|
|
2132
|
-
|
|
2133
|
-
txn = txn_model.model_dump()
|
|
2134
|
-
client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"])
|
|
2135
|
-
|
|
2136
|
-
approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS"))
|
|
2137
|
-
denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS"))
|
|
2138
|
-
|
|
2139
|
-
if client_key in approved:
|
|
2140
|
-
upstream_url = self._build_upstream_authorize_url(txn_id, txn)
|
|
2141
|
-
return RedirectResponse(url=upstream_url, status_code=302)
|
|
2142
|
-
|
|
2143
|
-
if client_key in denied:
|
|
2144
|
-
callback_params = {
|
|
2145
|
-
"error": "access_denied",
|
|
2146
|
-
"state": txn.get("client_state") or "",
|
|
2147
|
-
}
|
|
2148
|
-
sep = "&" if "?" in txn["client_redirect_uri"] else "?"
|
|
2149
|
-
return RedirectResponse(
|
|
2150
|
-
url=f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}",
|
|
2151
|
-
status_code=302,
|
|
2152
|
-
)
|
|
2153
|
-
|
|
2154
|
-
# Need consent: issue CSRF token and show HTML
|
|
2155
|
-
csrf_token = secrets.token_urlsafe(32)
|
|
2156
|
-
csrf_expires_at = time.time() + 15 * 60
|
|
2157
|
-
|
|
2158
|
-
# Update transaction with CSRF token
|
|
2159
|
-
txn_model.csrf_token = csrf_token
|
|
2160
|
-
txn_model.csrf_expires_at = csrf_expires_at
|
|
2161
|
-
await self._transaction_store.put(
|
|
2162
|
-
key=txn_id, value=txn_model, ttl=15 * 60
|
|
2163
|
-
) # Auto-expire after 15 minutes
|
|
2164
|
-
|
|
2165
|
-
# Update dict for use in HTML generation
|
|
2166
|
-
txn["csrf_token"] = csrf_token
|
|
2167
|
-
txn["csrf_expires_at"] = csrf_expires_at
|
|
2168
|
-
|
|
2169
|
-
# Load client to get client_name if available
|
|
2170
|
-
client = await self.get_client(txn["client_id"])
|
|
2171
|
-
client_name = getattr(client, "client_name", None) if client else None
|
|
2172
|
-
|
|
2173
|
-
# Extract server metadata from app state
|
|
2174
|
-
fastmcp = getattr(request.app.state, "fastmcp_server", None)
|
|
2175
|
-
|
|
2176
|
-
if isinstance(fastmcp, FastMCP):
|
|
2177
|
-
server_name = fastmcp.name
|
|
2178
|
-
icons = fastmcp.icons
|
|
2179
|
-
server_icon_url = icons[0].src if icons else None
|
|
2180
|
-
server_website_url = fastmcp.website_url
|
|
2181
|
-
else:
|
|
2182
|
-
server_name = None
|
|
2183
|
-
server_icon_url = None
|
|
2184
|
-
server_website_url = None
|
|
2185
|
-
|
|
2186
|
-
html = create_consent_html(
|
|
2187
|
-
client_id=txn["client_id"],
|
|
2188
|
-
redirect_uri=txn["client_redirect_uri"],
|
|
2189
|
-
scopes=txn.get("scopes") or [],
|
|
2190
|
-
txn_id=txn_id,
|
|
2191
|
-
csrf_token=csrf_token,
|
|
2192
|
-
client_name=client_name,
|
|
2193
|
-
server_name=server_name,
|
|
2194
|
-
server_icon_url=server_icon_url,
|
|
2195
|
-
server_website_url=server_website_url,
|
|
2196
|
-
csp_policy=self._consent_csp_policy,
|
|
2197
|
-
)
|
|
2198
|
-
response = create_secure_html_response(html)
|
|
2199
|
-
# Store CSRF in cookie with short lifetime
|
|
2200
|
-
self._set_list_cookie(
|
|
2201
|
-
response,
|
|
2202
|
-
"MCP_CONSENT_STATE",
|
|
2203
|
-
self._encode_list_cookie([csrf_token]),
|
|
2204
|
-
max_age=15 * 60,
|
|
2205
|
-
)
|
|
2206
|
-
return response
|
|
2207
|
-
|
|
2208
|
-
async def _submit_consent(
|
|
2209
|
-
self, request: Request
|
|
2210
|
-
) -> RedirectResponse | HTMLResponse:
|
|
2211
|
-
"""Handle consent approval/denial, set cookies, and redirect appropriately."""
|
|
2212
|
-
form = await request.form()
|
|
2213
|
-
txn_id = str(form.get("txn_id", ""))
|
|
2214
|
-
action = str(form.get("action", ""))
|
|
2215
|
-
csrf_token = str(form.get("csrf_token", ""))
|
|
2216
|
-
|
|
2217
|
-
if not txn_id:
|
|
2218
|
-
return create_secure_html_response(
|
|
2219
|
-
"<h1>Error</h1><p>Invalid or expired transaction</p>", status_code=400
|
|
2220
|
-
)
|
|
2221
|
-
|
|
2222
|
-
txn_model = await self._transaction_store.get(key=txn_id)
|
|
2223
|
-
if not txn_model:
|
|
2224
|
-
return create_secure_html_response(
|
|
2225
|
-
"<h1>Error</h1><p>Invalid or expired transaction</p>", status_code=400
|
|
2226
|
-
)
|
|
2227
|
-
|
|
2228
|
-
txn = txn_model.model_dump()
|
|
2229
|
-
expected_csrf = txn.get("csrf_token")
|
|
2230
|
-
expires_at = float(txn.get("csrf_expires_at") or 0)
|
|
2231
|
-
|
|
2232
|
-
if not expected_csrf or csrf_token != expected_csrf or time.time() > expires_at:
|
|
2233
|
-
return create_secure_html_response(
|
|
2234
|
-
"<h1>Error</h1><p>Invalid or expired consent token</p>", status_code=400
|
|
2235
|
-
)
|
|
2236
|
-
|
|
2237
|
-
client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"])
|
|
2238
|
-
|
|
2239
|
-
if action == "approve":
|
|
2240
|
-
approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS"))
|
|
2241
|
-
if client_key not in approved:
|
|
2242
|
-
approved.add(client_key)
|
|
2243
|
-
approved_b64 = self._encode_list_cookie(sorted(approved))
|
|
2244
|
-
|
|
2245
|
-
upstream_url = self._build_upstream_authorize_url(txn_id, txn)
|
|
2246
|
-
response = RedirectResponse(url=upstream_url, status_code=302)
|
|
2247
|
-
self._set_list_cookie(
|
|
2248
|
-
response, "MCP_APPROVED_CLIENTS", approved_b64, max_age=365 * 24 * 3600
|
|
2249
|
-
)
|
|
2250
|
-
# Clear CSRF cookie by setting empty short-lived value
|
|
2251
|
-
self._set_list_cookie(
|
|
2252
|
-
response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60
|
|
2253
|
-
)
|
|
2254
|
-
return response
|
|
2255
|
-
|
|
2256
|
-
elif action == "deny":
|
|
2257
|
-
denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS"))
|
|
2258
|
-
if client_key not in denied:
|
|
2259
|
-
denied.add(client_key)
|
|
2260
|
-
denied_b64 = self._encode_list_cookie(sorted(denied))
|
|
2261
|
-
|
|
2262
|
-
callback_params = {
|
|
2263
|
-
"error": "access_denied",
|
|
2264
|
-
"state": txn.get("client_state") or "",
|
|
2265
|
-
}
|
|
2266
|
-
sep = "&" if "?" in txn["client_redirect_uri"] else "?"
|
|
2267
|
-
client_callback_url = (
|
|
2268
|
-
f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}"
|
|
2269
|
-
)
|
|
2270
|
-
response = RedirectResponse(url=client_callback_url, status_code=302)
|
|
2271
|
-
self._set_list_cookie(
|
|
2272
|
-
response, "MCP_DENIED_CLIENTS", denied_b64, max_age=365 * 24 * 3600
|
|
2273
|
-
)
|
|
2274
|
-
self._set_list_cookie(
|
|
2275
|
-
response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60
|
|
2276
|
-
)
|
|
2277
|
-
return response
|
|
2278
|
-
|
|
2279
|
-
else:
|
|
2280
|
-
return create_secure_html_response(
|
|
2281
|
-
"<h1>Error</h1><p>Invalid action</p>", status_code=400
|
|
2282
|
-
)
|