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.
Files changed (175) hide show
  1. fastmcp/_vendor/__init__.py +1 -0
  2. fastmcp/_vendor/docket_di/README.md +7 -0
  3. fastmcp/_vendor/docket_di/__init__.py +163 -0
  4. fastmcp/cli/cli.py +112 -28
  5. fastmcp/cli/install/claude_code.py +1 -5
  6. fastmcp/cli/install/claude_desktop.py +1 -5
  7. fastmcp/cli/install/cursor.py +1 -5
  8. fastmcp/cli/install/gemini_cli.py +1 -5
  9. fastmcp/cli/install/mcp_json.py +1 -6
  10. fastmcp/cli/run.py +146 -5
  11. fastmcp/client/__init__.py +7 -9
  12. fastmcp/client/auth/oauth.py +18 -17
  13. fastmcp/client/client.py +100 -870
  14. fastmcp/client/elicitation.py +1 -1
  15. fastmcp/client/mixins/__init__.py +13 -0
  16. fastmcp/client/mixins/prompts.py +295 -0
  17. fastmcp/client/mixins/resources.py +325 -0
  18. fastmcp/client/mixins/task_management.py +157 -0
  19. fastmcp/client/mixins/tools.py +397 -0
  20. fastmcp/client/sampling/handlers/anthropic.py +2 -2
  21. fastmcp/client/sampling/handlers/openai.py +1 -1
  22. fastmcp/client/tasks.py +3 -3
  23. fastmcp/client/telemetry.py +47 -0
  24. fastmcp/client/transports/__init__.py +38 -0
  25. fastmcp/client/transports/base.py +82 -0
  26. fastmcp/client/transports/config.py +170 -0
  27. fastmcp/client/transports/http.py +145 -0
  28. fastmcp/client/transports/inference.py +154 -0
  29. fastmcp/client/transports/memory.py +90 -0
  30. fastmcp/client/transports/sse.py +89 -0
  31. fastmcp/client/transports/stdio.py +543 -0
  32. fastmcp/contrib/component_manager/README.md +4 -10
  33. fastmcp/contrib/component_manager/__init__.py +1 -2
  34. fastmcp/contrib/component_manager/component_manager.py +95 -160
  35. fastmcp/contrib/component_manager/example.py +1 -1
  36. fastmcp/contrib/mcp_mixin/example.py +4 -4
  37. fastmcp/contrib/mcp_mixin/mcp_mixin.py +11 -4
  38. fastmcp/decorators.py +41 -0
  39. fastmcp/dependencies.py +12 -1
  40. fastmcp/exceptions.py +4 -0
  41. fastmcp/experimental/server/openapi/__init__.py +18 -15
  42. fastmcp/mcp_config.py +13 -4
  43. fastmcp/prompts/__init__.py +6 -3
  44. fastmcp/prompts/function_prompt.py +465 -0
  45. fastmcp/prompts/prompt.py +321 -271
  46. fastmcp/resources/__init__.py +5 -3
  47. fastmcp/resources/function_resource.py +335 -0
  48. fastmcp/resources/resource.py +325 -115
  49. fastmcp/resources/template.py +215 -43
  50. fastmcp/resources/types.py +27 -12
  51. fastmcp/server/__init__.py +2 -2
  52. fastmcp/server/auth/__init__.py +14 -0
  53. fastmcp/server/auth/auth.py +30 -10
  54. fastmcp/server/auth/authorization.py +190 -0
  55. fastmcp/server/auth/oauth_proxy/__init__.py +14 -0
  56. fastmcp/server/auth/oauth_proxy/consent.py +361 -0
  57. fastmcp/server/auth/oauth_proxy/models.py +178 -0
  58. fastmcp/server/auth/{oauth_proxy.py → oauth_proxy/proxy.py} +24 -778
  59. fastmcp/server/auth/oauth_proxy/ui.py +277 -0
  60. fastmcp/server/auth/oidc_proxy.py +2 -2
  61. fastmcp/server/auth/providers/auth0.py +24 -94
  62. fastmcp/server/auth/providers/aws.py +26 -95
  63. fastmcp/server/auth/providers/azure.py +41 -129
  64. fastmcp/server/auth/providers/descope.py +18 -49
  65. fastmcp/server/auth/providers/discord.py +25 -86
  66. fastmcp/server/auth/providers/github.py +23 -87
  67. fastmcp/server/auth/providers/google.py +24 -87
  68. fastmcp/server/auth/providers/introspection.py +60 -79
  69. fastmcp/server/auth/providers/jwt.py +30 -67
  70. fastmcp/server/auth/providers/oci.py +47 -110
  71. fastmcp/server/auth/providers/scalekit.py +23 -61
  72. fastmcp/server/auth/providers/supabase.py +18 -47
  73. fastmcp/server/auth/providers/workos.py +34 -127
  74. fastmcp/server/context.py +372 -419
  75. fastmcp/server/dependencies.py +541 -251
  76. fastmcp/server/elicitation.py +20 -18
  77. fastmcp/server/event_store.py +3 -3
  78. fastmcp/server/http.py +16 -6
  79. fastmcp/server/lifespan.py +198 -0
  80. fastmcp/server/low_level.py +92 -2
  81. fastmcp/server/middleware/__init__.py +5 -1
  82. fastmcp/server/middleware/authorization.py +312 -0
  83. fastmcp/server/middleware/caching.py +101 -54
  84. fastmcp/server/middleware/middleware.py +6 -9
  85. fastmcp/server/middleware/ping.py +70 -0
  86. fastmcp/server/middleware/tool_injection.py +2 -2
  87. fastmcp/server/mixins/__init__.py +7 -0
  88. fastmcp/server/mixins/lifespan.py +217 -0
  89. fastmcp/server/mixins/mcp_operations.py +392 -0
  90. fastmcp/server/mixins/transport.py +342 -0
  91. fastmcp/server/openapi/__init__.py +41 -21
  92. fastmcp/server/openapi/components.py +16 -339
  93. fastmcp/server/openapi/routing.py +34 -118
  94. fastmcp/server/openapi/server.py +67 -392
  95. fastmcp/server/providers/__init__.py +71 -0
  96. fastmcp/server/providers/aggregate.py +261 -0
  97. fastmcp/server/providers/base.py +578 -0
  98. fastmcp/server/providers/fastmcp_provider.py +674 -0
  99. fastmcp/server/providers/filesystem.py +226 -0
  100. fastmcp/server/providers/filesystem_discovery.py +327 -0
  101. fastmcp/server/providers/local_provider/__init__.py +11 -0
  102. fastmcp/server/providers/local_provider/decorators/__init__.py +15 -0
  103. fastmcp/server/providers/local_provider/decorators/prompts.py +256 -0
  104. fastmcp/server/providers/local_provider/decorators/resources.py +240 -0
  105. fastmcp/server/providers/local_provider/decorators/tools.py +315 -0
  106. fastmcp/server/providers/local_provider/local_provider.py +465 -0
  107. fastmcp/server/providers/openapi/__init__.py +39 -0
  108. fastmcp/server/providers/openapi/components.py +332 -0
  109. fastmcp/server/providers/openapi/provider.py +405 -0
  110. fastmcp/server/providers/openapi/routing.py +109 -0
  111. fastmcp/server/providers/proxy.py +867 -0
  112. fastmcp/server/providers/skills/__init__.py +59 -0
  113. fastmcp/server/providers/skills/_common.py +101 -0
  114. fastmcp/server/providers/skills/claude_provider.py +44 -0
  115. fastmcp/server/providers/skills/directory_provider.py +153 -0
  116. fastmcp/server/providers/skills/skill_provider.py +432 -0
  117. fastmcp/server/providers/skills/vendor_providers.py +142 -0
  118. fastmcp/server/providers/wrapped_provider.py +140 -0
  119. fastmcp/server/proxy.py +34 -700
  120. fastmcp/server/sampling/run.py +341 -2
  121. fastmcp/server/sampling/sampling_tool.py +4 -3
  122. fastmcp/server/server.py +1214 -2171
  123. fastmcp/server/tasks/__init__.py +2 -1
  124. fastmcp/server/tasks/capabilities.py +13 -1
  125. fastmcp/server/tasks/config.py +66 -3
  126. fastmcp/server/tasks/handlers.py +65 -273
  127. fastmcp/server/tasks/keys.py +4 -6
  128. fastmcp/server/tasks/requests.py +474 -0
  129. fastmcp/server/tasks/routing.py +76 -0
  130. fastmcp/server/tasks/subscriptions.py +20 -11
  131. fastmcp/server/telemetry.py +131 -0
  132. fastmcp/server/transforms/__init__.py +244 -0
  133. fastmcp/server/transforms/namespace.py +193 -0
  134. fastmcp/server/transforms/prompts_as_tools.py +175 -0
  135. fastmcp/server/transforms/resources_as_tools.py +190 -0
  136. fastmcp/server/transforms/tool_transform.py +96 -0
  137. fastmcp/server/transforms/version_filter.py +124 -0
  138. fastmcp/server/transforms/visibility.py +526 -0
  139. fastmcp/settings.py +34 -96
  140. fastmcp/telemetry.py +122 -0
  141. fastmcp/tools/__init__.py +10 -3
  142. fastmcp/tools/function_parsing.py +201 -0
  143. fastmcp/tools/function_tool.py +467 -0
  144. fastmcp/tools/tool.py +215 -362
  145. fastmcp/tools/tool_transform.py +38 -21
  146. fastmcp/utilities/async_utils.py +69 -0
  147. fastmcp/utilities/components.py +152 -91
  148. fastmcp/utilities/inspect.py +8 -20
  149. fastmcp/utilities/json_schema.py +12 -5
  150. fastmcp/utilities/json_schema_type.py +17 -15
  151. fastmcp/utilities/lifespan.py +56 -0
  152. fastmcp/utilities/logging.py +12 -4
  153. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
  154. fastmcp/utilities/openapi/parser.py +3 -3
  155. fastmcp/utilities/pagination.py +80 -0
  156. fastmcp/utilities/skills.py +253 -0
  157. fastmcp/utilities/tests.py +0 -16
  158. fastmcp/utilities/timeout.py +47 -0
  159. fastmcp/utilities/types.py +1 -1
  160. fastmcp/utilities/versions.py +285 -0
  161. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/METADATA +8 -5
  162. fastmcp-3.0.0b1.dist-info/RECORD +228 -0
  163. fastmcp/client/transports.py +0 -1170
  164. fastmcp/contrib/component_manager/component_service.py +0 -209
  165. fastmcp/prompts/prompt_manager.py +0 -117
  166. fastmcp/resources/resource_manager.py +0 -338
  167. fastmcp/server/tasks/converters.py +0 -206
  168. fastmcp/server/tasks/protocol.py +0 -359
  169. fastmcp/tools/tool_manager.py +0 -170
  170. fastmcp/utilities/mcp_config.py +0 -56
  171. fastmcp-2.14.4.dist-info/RECORD +0 -161
  172. /fastmcp/server/{openapi → providers/openapi}/README.md +0 -0
  173. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/WHEEL +0 -0
  174. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/entry_points.txt +0 -0
  175. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,190 @@
1
+ """Authorization checks for FastMCP components.
2
+
3
+ This module provides callable-based authorization for tools, resources, and prompts.
4
+ Auth checks are functions that receive an AuthContext and return True to allow access
5
+ or False to deny.
6
+
7
+ Auth checks can also raise exceptions:
8
+ - AuthorizationError: Propagates with the custom message for explicit denial
9
+ - Other exceptions: Masked for security (logged, treated as auth failure)
10
+
11
+ Example:
12
+ ```python
13
+ from fastmcp import FastMCP
14
+ from fastmcp.server.auth import require_auth, require_scopes
15
+
16
+ mcp = FastMCP()
17
+
18
+ @mcp.tool(auth=require_auth)
19
+ def protected_tool(): ...
20
+
21
+ @mcp.resource("data://secret", auth=require_scopes("read"))
22
+ def secret_data(): ...
23
+
24
+ @mcp.prompt(auth=require_auth)
25
+ def admin_prompt(): ...
26
+ ```
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ from collections.abc import Callable
33
+ from dataclasses import dataclass
34
+ from typing import TYPE_CHECKING, cast
35
+
36
+ from fastmcp.exceptions import AuthorizationError
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ if TYPE_CHECKING:
41
+ from fastmcp.server.auth import AccessToken
42
+ from fastmcp.tools.tool import Tool
43
+ from fastmcp.utilities.components import FastMCPComponent
44
+
45
+
46
+ @dataclass
47
+ class AuthContext:
48
+ """Context passed to auth check callables.
49
+
50
+ This object is passed to each auth check function and provides
51
+ access to the current authentication token and the component being accessed.
52
+
53
+ Attributes:
54
+ token: The current access token, or None if unauthenticated.
55
+ component: The component (tool, resource, or prompt) being accessed.
56
+ tool: Backwards-compatible alias for component when it's a Tool.
57
+ """
58
+
59
+ token: AccessToken | None
60
+ component: FastMCPComponent
61
+
62
+ @property
63
+ def tool(self) -> Tool | None:
64
+ """Backwards-compatible access to the component as a Tool.
65
+
66
+ Returns the component if it's a Tool, None otherwise.
67
+ """
68
+ from fastmcp.tools.tool import Tool
69
+
70
+ return self.component if isinstance(self.component, Tool) else None
71
+
72
+
73
+ # Type alias for auth check functions
74
+ AuthCheck = Callable[[AuthContext], bool]
75
+
76
+
77
+ def require_auth(ctx: AuthContext) -> bool:
78
+ """Require any valid authentication.
79
+
80
+ Returns True if the request has a valid token, False otherwise.
81
+
82
+ Example:
83
+ ```python
84
+ @mcp.tool(auth=require_auth)
85
+ def protected_tool(): ...
86
+ ```
87
+ """
88
+ return ctx.token is not None
89
+
90
+
91
+ def require_scopes(*scopes: str) -> AuthCheck:
92
+ """Require specific OAuth scopes.
93
+
94
+ Returns an auth check that requires ALL specified scopes to be present
95
+ in the token (AND logic).
96
+
97
+ Args:
98
+ *scopes: One or more scope strings that must all be present.
99
+
100
+ Example:
101
+ ```python
102
+ @mcp.tool(auth=require_scopes("admin"))
103
+ def admin_tool(): ...
104
+
105
+ @mcp.tool(auth=require_scopes("read", "write"))
106
+ def read_write_tool(): ...
107
+ ```
108
+ """
109
+ required = set(scopes)
110
+
111
+ def check(ctx: AuthContext) -> bool:
112
+ if ctx.token is None:
113
+ return False
114
+ return required.issubset(set(ctx.token.scopes))
115
+
116
+ return check
117
+
118
+
119
+ def restrict_tag(tag: str, *, scopes: list[str]) -> AuthCheck:
120
+ """Restrict components with a specific tag to require certain scopes.
121
+
122
+ If the component has the specified tag, the token must have ALL the
123
+ required scopes. If the component doesn't have the tag, access is allowed.
124
+
125
+ Args:
126
+ tag: The tag that triggers the scope requirement.
127
+ scopes: List of scopes required when the tag is present.
128
+
129
+ Example:
130
+ ```python
131
+ # Components tagged "admin" require the "admin" scope
132
+ AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"]))
133
+ ```
134
+ """
135
+ required = set(scopes)
136
+
137
+ def check(ctx: AuthContext) -> bool:
138
+ if tag not in ctx.component.tags:
139
+ return True # Tag not present, no restriction
140
+ if ctx.token is None:
141
+ return False
142
+ return required.issubset(set(ctx.token.scopes))
143
+
144
+ return check
145
+
146
+
147
+ def run_auth_checks(
148
+ checks: AuthCheck | list[AuthCheck],
149
+ ctx: AuthContext,
150
+ ) -> bool:
151
+ """Run auth checks with AND logic.
152
+
153
+ All checks must pass for authorization to succeed.
154
+
155
+ Auth checks can:
156
+ - Return True to allow access
157
+ - Return False to deny access
158
+ - Raise AuthorizationError to deny with a custom message (propagates)
159
+ - Raise other exceptions (masked for security, treated as denial)
160
+
161
+ Args:
162
+ checks: A single check function or list of check functions.
163
+ ctx: The auth context to pass to each check.
164
+
165
+ Returns:
166
+ True if all checks pass, False if any check fails.
167
+
168
+ Raises:
169
+ AuthorizationError: If an auth check explicitly raises it.
170
+ """
171
+ check_list = [checks] if not isinstance(checks, list) else checks
172
+ check_list = cast(list[AuthCheck], check_list)
173
+
174
+ for check in check_list:
175
+ try:
176
+ if not check(ctx):
177
+ return False
178
+ except AuthorizationError:
179
+ # Let AuthorizationError propagate with its custom message
180
+ raise
181
+ except Exception:
182
+ # Mask other exceptions for security - log and treat as auth failure
183
+ logger.warning(
184
+ f"Auth check {getattr(check, '__name__', repr(check))} "
185
+ "raised an unexpected exception",
186
+ exc_info=True,
187
+ )
188
+ return False
189
+
190
+ return True
@@ -0,0 +1,14 @@
1
+ """OAuth Proxy Provider for FastMCP.
2
+
3
+ This package provides OAuth proxy functionality split across multiple modules:
4
+ - models: Pydantic models and constants
5
+ - ui: HTML generation functions
6
+ - consent: Consent management mixin
7
+ - proxy: Main OAuthProxy class
8
+ """
9
+
10
+ from fastmcp.server.auth.oauth_proxy.proxy import OAuthProxy
11
+
12
+ __all__ = [
13
+ "OAuthProxy",
14
+ ]
@@ -0,0 +1,361 @@
1
+ """OAuth Proxy Consent Management.
2
+
3
+ This module contains consent management functionality for the OAuth proxy.
4
+ The ConsentMixin class provides methods for handling user consent flows,
5
+ cookie management, and consent page rendering.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import hmac
13
+ import json
14
+ import secrets
15
+ import time
16
+ from base64 import urlsafe_b64encode
17
+ from typing import TYPE_CHECKING, Any
18
+ from urllib.parse import urlencode, urlparse
19
+
20
+ from pydantic import AnyUrl
21
+ from starlette.requests import Request
22
+ from starlette.responses import HTMLResponse, RedirectResponse
23
+
24
+ from fastmcp.server.auth.oauth_proxy.ui import create_consent_html
25
+ from fastmcp.utilities.logging import get_logger
26
+ from fastmcp.utilities.ui import create_secure_html_response
27
+
28
+ if TYPE_CHECKING:
29
+ from fastmcp.server.auth.oauth_proxy.proxy import OAuthProxy
30
+
31
+ logger = get_logger(__name__)
32
+
33
+
34
+ class ConsentMixin:
35
+ """Mixin class providing consent management functionality for OAuthProxy.
36
+
37
+ This mixin contains all methods related to:
38
+ - Cookie signing and verification
39
+ - Consent page rendering
40
+ - Consent approval/denial handling
41
+ - URI normalization for consent tracking
42
+ """
43
+
44
+ def _normalize_uri(self, uri: str) -> str:
45
+ """Normalize a URI to a canonical form for consent tracking."""
46
+ parsed = urlparse(uri)
47
+ path = parsed.path or ""
48
+ normalized = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{path}"
49
+ if normalized.endswith("/") and len(path) > 1:
50
+ normalized = normalized[:-1]
51
+ return normalized
52
+
53
+ def _make_client_key(self, client_id: str, redirect_uri: str | AnyUrl) -> str:
54
+ """Create a stable key for consent tracking from client_id and redirect_uri."""
55
+ normalized = self._normalize_uri(str(redirect_uri))
56
+ return f"{client_id}:{normalized}"
57
+
58
+ def _cookie_name(self: OAuthProxy, base_name: str) -> str:
59
+ """Return secure cookie name for HTTPS, fallback for HTTP development."""
60
+ if self._is_https:
61
+ return f"__Host-{base_name}"
62
+ return f"__{base_name}"
63
+
64
+ def _sign_cookie(self: OAuthProxy, payload: str) -> str:
65
+ """Sign a cookie payload with HMAC-SHA256.
66
+
67
+ Returns: base64(payload).base64(signature)
68
+ """
69
+ # Use upstream client secret as signing key
70
+ key = self._upstream_client_secret.get_secret_value().encode()
71
+ signature = hmac.new(key, payload.encode(), hashlib.sha256).digest()
72
+ signature_b64 = base64.b64encode(signature).decode()
73
+ return f"{payload}.{signature_b64}"
74
+
75
+ def _verify_cookie(self: OAuthProxy, signed_value: str) -> str | None:
76
+ """Verify and extract payload from signed cookie.
77
+
78
+ Returns: payload if signature valid, None otherwise
79
+ """
80
+ try:
81
+ if "." not in signed_value:
82
+ return None
83
+ payload, signature_b64 = signed_value.rsplit(".", 1)
84
+
85
+ # Verify signature
86
+ key = self._upstream_client_secret.get_secret_value().encode()
87
+ expected_sig = hmac.new(key, payload.encode(), hashlib.sha256).digest()
88
+ provided_sig = base64.b64decode(signature_b64.encode())
89
+
90
+ # Constant-time comparison
91
+ if not hmac.compare_digest(expected_sig, provided_sig):
92
+ return None
93
+
94
+ return payload
95
+ except Exception:
96
+ return None
97
+
98
+ def _decode_list_cookie(
99
+ self: OAuthProxy, request: Request, base_name: str
100
+ ) -> list[str]:
101
+ """Decode and verify a signed base64-encoded JSON list from cookie. Returns [] if missing/invalid."""
102
+ # Prefer secure name, but also check non-secure variant for dev
103
+ secure_name = self._cookie_name(base_name)
104
+ raw = request.cookies.get(secure_name) or request.cookies.get(f"__{base_name}")
105
+ if not raw:
106
+ return []
107
+ try:
108
+ # Verify signature
109
+ payload = self._verify_cookie(raw)
110
+ if not payload:
111
+ logger.debug("Cookie signature verification failed for %s", secure_name)
112
+ return []
113
+
114
+ # Decode payload
115
+ data = base64.b64decode(payload.encode())
116
+ value = json.loads(data.decode())
117
+ if isinstance(value, list):
118
+ return [str(x) for x in value]
119
+ except Exception:
120
+ logger.debug("Failed to decode cookie %s; treating as empty", secure_name)
121
+ return []
122
+
123
+ def _encode_list_cookie(self: OAuthProxy, values: list[str]) -> str:
124
+ """Encode values to base64 and sign with HMAC.
125
+
126
+ Returns: signed cookie value (payload.signature)
127
+ """
128
+ payload = json.dumps(values, separators=(",", ":")).encode()
129
+ payload_b64 = base64.b64encode(payload).decode()
130
+ return self._sign_cookie(payload_b64)
131
+
132
+ def _set_list_cookie(
133
+ self: OAuthProxy,
134
+ response: HTMLResponse | RedirectResponse,
135
+ base_name: str,
136
+ value_b64: str,
137
+ max_age: int,
138
+ ) -> None:
139
+ name = self._cookie_name(base_name)
140
+ response.set_cookie(
141
+ name,
142
+ value_b64,
143
+ max_age=max_age,
144
+ secure=self._is_https,
145
+ httponly=True,
146
+ samesite="lax",
147
+ path="/",
148
+ )
149
+
150
+ def _build_upstream_authorize_url(
151
+ self: OAuthProxy, txn_id: str, transaction: dict[str, Any]
152
+ ) -> str:
153
+ """Construct the upstream IdP authorization URL using stored transaction data."""
154
+ query_params: dict[str, Any] = {
155
+ "response_type": "code",
156
+ "client_id": self._upstream_client_id,
157
+ "redirect_uri": f"{str(self.base_url).rstrip('/')}{self._redirect_path}",
158
+ "state": txn_id,
159
+ }
160
+
161
+ scopes_to_use = transaction.get("scopes") or self.required_scopes or []
162
+ if scopes_to_use:
163
+ query_params["scope"] = " ".join(scopes_to_use)
164
+
165
+ # If PKCE forwarding was enabled, include the proxy challenge
166
+ proxy_code_verifier = transaction.get("proxy_code_verifier")
167
+ if proxy_code_verifier:
168
+ challenge_bytes = hashlib.sha256(proxy_code_verifier.encode()).digest()
169
+ proxy_code_challenge = (
170
+ urlsafe_b64encode(challenge_bytes).decode().rstrip("=")
171
+ )
172
+ query_params["code_challenge"] = proxy_code_challenge
173
+ query_params["code_challenge_method"] = "S256"
174
+
175
+ # Forward resource indicator if present in transaction
176
+ if resource := transaction.get("resource"):
177
+ query_params["resource"] = resource
178
+
179
+ # Extra configured parameters
180
+ if self._extra_authorize_params:
181
+ query_params.update(self._extra_authorize_params)
182
+
183
+ separator = "&" if "?" in self._upstream_authorization_endpoint else "?"
184
+ return f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}"
185
+
186
+ async def _handle_consent(
187
+ self: OAuthProxy, request: Request
188
+ ) -> HTMLResponse | RedirectResponse:
189
+ """Handle consent page - dispatch to GET or POST handler based on method."""
190
+ if request.method == "POST":
191
+ return await self._submit_consent(request)
192
+ return await self._show_consent_page(request)
193
+
194
+ async def _show_consent_page(
195
+ self: OAuthProxy, request: Request
196
+ ) -> HTMLResponse | RedirectResponse:
197
+ """Display consent page or auto-approve/deny based on cookies."""
198
+ from fastmcp.server.server import FastMCP
199
+
200
+ txn_id = request.query_params.get("txn_id")
201
+ if not txn_id:
202
+ return create_secure_html_response(
203
+ "<h1>Error</h1><p>Invalid or expired transaction</p>", status_code=400
204
+ )
205
+
206
+ txn_model = await self._transaction_store.get(key=txn_id)
207
+ if not txn_model:
208
+ return create_secure_html_response(
209
+ "<h1>Error</h1><p>Invalid or expired transaction</p>", status_code=400
210
+ )
211
+
212
+ txn = txn_model.model_dump()
213
+ client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"])
214
+
215
+ approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS"))
216
+ denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS"))
217
+
218
+ if client_key in approved:
219
+ upstream_url = self._build_upstream_authorize_url(txn_id, txn)
220
+ return RedirectResponse(url=upstream_url, status_code=302)
221
+
222
+ if client_key in denied:
223
+ callback_params = {
224
+ "error": "access_denied",
225
+ "state": txn.get("client_state") or "",
226
+ }
227
+ sep = "&" if "?" in txn["client_redirect_uri"] else "?"
228
+ return RedirectResponse(
229
+ url=f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}",
230
+ status_code=302,
231
+ )
232
+
233
+ # Need consent: issue CSRF token and show HTML
234
+ csrf_token = secrets.token_urlsafe(32)
235
+ csrf_expires_at = time.time() + 15 * 60
236
+
237
+ # Update transaction with CSRF token
238
+ txn_model.csrf_token = csrf_token
239
+ txn_model.csrf_expires_at = csrf_expires_at
240
+ await self._transaction_store.put(
241
+ key=txn_id, value=txn_model, ttl=15 * 60
242
+ ) # Auto-expire after 15 minutes
243
+
244
+ # Update dict for use in HTML generation
245
+ txn["csrf_token"] = csrf_token
246
+ txn["csrf_expires_at"] = csrf_expires_at
247
+
248
+ # Load client to get client_name if available
249
+ client = await self.get_client(txn["client_id"])
250
+ client_name = getattr(client, "client_name", None) if client else None
251
+
252
+ # Extract server metadata from app state
253
+ fastmcp = getattr(request.app.state, "fastmcp_server", None)
254
+
255
+ if isinstance(fastmcp, FastMCP):
256
+ server_name = fastmcp.name
257
+ icons = fastmcp.icons
258
+ server_icon_url = icons[0].src if icons else None
259
+ server_website_url = fastmcp.website_url
260
+ else:
261
+ server_name = None
262
+ server_icon_url = None
263
+ server_website_url = None
264
+
265
+ html = create_consent_html(
266
+ client_id=txn["client_id"],
267
+ redirect_uri=txn["client_redirect_uri"],
268
+ scopes=txn.get("scopes") or [],
269
+ txn_id=txn_id,
270
+ csrf_token=csrf_token,
271
+ client_name=client_name,
272
+ server_name=server_name,
273
+ server_icon_url=server_icon_url,
274
+ server_website_url=server_website_url,
275
+ csp_policy=self._consent_csp_policy,
276
+ )
277
+ response = create_secure_html_response(html)
278
+ # Store CSRF in cookie with short lifetime
279
+ self._set_list_cookie(
280
+ response,
281
+ "MCP_CONSENT_STATE",
282
+ self._encode_list_cookie([csrf_token]),
283
+ max_age=15 * 60,
284
+ )
285
+ return response
286
+
287
+ async def _submit_consent(
288
+ self: OAuthProxy, request: Request
289
+ ) -> RedirectResponse | HTMLResponse:
290
+ """Handle consent approval/denial, set cookies, and redirect appropriately."""
291
+ form = await request.form()
292
+ txn_id = str(form.get("txn_id", ""))
293
+ action = str(form.get("action", ""))
294
+ csrf_token = str(form.get("csrf_token", ""))
295
+
296
+ if not txn_id:
297
+ return create_secure_html_response(
298
+ "<h1>Error</h1><p>Invalid or expired transaction</p>", status_code=400
299
+ )
300
+
301
+ txn_model = await self._transaction_store.get(key=txn_id)
302
+ if not txn_model:
303
+ return create_secure_html_response(
304
+ "<h1>Error</h1><p>Invalid or expired transaction</p>", status_code=400
305
+ )
306
+
307
+ txn = txn_model.model_dump()
308
+ expected_csrf = txn.get("csrf_token")
309
+ expires_at = float(txn.get("csrf_expires_at") or 0)
310
+
311
+ if not expected_csrf or csrf_token != expected_csrf or time.time() > expires_at:
312
+ return create_secure_html_response(
313
+ "<h1>Error</h1><p>Invalid or expired consent token</p>", status_code=400
314
+ )
315
+
316
+ client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"])
317
+
318
+ if action == "approve":
319
+ approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS"))
320
+ if client_key not in approved:
321
+ approved.add(client_key)
322
+ approved_b64 = self._encode_list_cookie(sorted(approved))
323
+
324
+ upstream_url = self._build_upstream_authorize_url(txn_id, txn)
325
+ response = RedirectResponse(url=upstream_url, status_code=302)
326
+ self._set_list_cookie(
327
+ response, "MCP_APPROVED_CLIENTS", approved_b64, max_age=365 * 24 * 3600
328
+ )
329
+ # Clear CSRF cookie by setting empty short-lived value
330
+ self._set_list_cookie(
331
+ response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60
332
+ )
333
+ return response
334
+
335
+ elif action == "deny":
336
+ denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS"))
337
+ if client_key not in denied:
338
+ denied.add(client_key)
339
+ denied_b64 = self._encode_list_cookie(sorted(denied))
340
+
341
+ callback_params = {
342
+ "error": "access_denied",
343
+ "state": txn.get("client_state") or "",
344
+ }
345
+ sep = "&" if "?" in txn["client_redirect_uri"] else "?"
346
+ client_callback_url = (
347
+ f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}"
348
+ )
349
+ response = RedirectResponse(url=client_callback_url, status_code=302)
350
+ self._set_list_cookie(
351
+ response, "MCP_DENIED_CLIENTS", denied_b64, max_age=365 * 24 * 3600
352
+ )
353
+ self._set_list_cookie(
354
+ response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60
355
+ )
356
+ return response
357
+
358
+ else:
359
+ return create_secure_html_response(
360
+ "<h1>Error</h1><p>Invalid action</p>", status_code=400
361
+ )