fastmcp 2.14.5__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.5.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.5.dist-info/RECORD +0 -161
- /fastmcp/server/{openapi → providers/openapi}/README.md +0 -0
- {fastmcp-2.14.5.dist-info → fastmcp-3.0.0b1.dist-info}/WHEEL +0 -0
- {fastmcp-2.14.5.dist-info → fastmcp-3.0.0b1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.14.5.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
|
+
)
|