Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
7befcc3
1
Parent(s): 7218a9e
Add basic server primitives
Browse files
src/fastmcp/server/auth/__init__.py
ADDED
|
File without changes
|
src/fastmcp/server/auth/auth.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from mcp.server.auth.provider import (
|
| 2 |
+
AccessToken,
|
| 3 |
+
AuthorizationCode,
|
| 4 |
+
OAuthAuthorizationServerProvider,
|
| 5 |
+
RefreshToken,
|
| 6 |
+
)
|
| 7 |
+
from mcp.server.auth.settings import (
|
| 8 |
+
AuthSettings,
|
| 9 |
+
ClientRegistrationOptions,
|
| 10 |
+
RevocationOptions,
|
| 11 |
+
)
|
| 12 |
+
from pydantic import AnyHttpUrl
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class OAuthProvider(
|
| 16 |
+
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
|
| 17 |
+
):
|
| 18 |
+
def __init__(
|
| 19 |
+
self,
|
| 20 |
+
issuer_url: AnyHttpUrl | str,
|
| 21 |
+
service_documentation_url: AnyHttpUrl | str | None = None,
|
| 22 |
+
client_registration_options: ClientRegistrationOptions | None = None,
|
| 23 |
+
revocation_options: RevocationOptions | None = None,
|
| 24 |
+
required_scopes: list[str] | None = None,
|
| 25 |
+
):
|
| 26 |
+
super().__init__()
|
| 27 |
+
if isinstance(issuer_url, str):
|
| 28 |
+
issuer_url = AnyHttpUrl(issuer_url)
|
| 29 |
+
if isinstance(service_documentation_url, str):
|
| 30 |
+
service_documentation_url = AnyHttpUrl(service_documentation_url)
|
| 31 |
+
|
| 32 |
+
self.settings = AuthSettings(
|
| 33 |
+
issuer_url=issuer_url,
|
| 34 |
+
service_documentation_url=service_documentation_url,
|
| 35 |
+
client_registration_options=client_registration_options,
|
| 36 |
+
revocation_options=revocation_options,
|
| 37 |
+
required_scopes=required_scopes,
|
| 38 |
+
)
|
src/fastmcp/server/auth/in_memory_provider.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import secrets
|
| 2 |
+
import time
|
| 3 |
+
|
| 4 |
+
from mcp.server.auth.provider import (
|
| 5 |
+
AccessToken,
|
| 6 |
+
AuthorizationCode,
|
| 7 |
+
AuthorizationParams,
|
| 8 |
+
AuthorizeError,
|
| 9 |
+
RefreshToken,
|
| 10 |
+
TokenError,
|
| 11 |
+
construct_redirect_uri,
|
| 12 |
+
)
|
| 13 |
+
from mcp.shared.auth import (
|
| 14 |
+
OAuthClientInformationFull,
|
| 15 |
+
OAuthToken,
|
| 16 |
+
)
|
| 17 |
+
from pydantic import AnyHttpUrl
|
| 18 |
+
|
| 19 |
+
from fastmcp.server.auth.auth import (
|
| 20 |
+
ClientRegistrationOptions,
|
| 21 |
+
OAuthProvider,
|
| 22 |
+
RevocationOptions,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# Default expiration times (in seconds)
|
| 26 |
+
DEFAULT_AUTH_CODE_EXPIRY_SECONDS = 5 * 60 # 5 minutes
|
| 27 |
+
DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS = 60 * 60 # 1 hour
|
| 28 |
+
# Refresh tokens often have longer or no expiry; let's make them non-expiring for simplicity
|
| 29 |
+
DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class InMemoryOAuthProvider(OAuthProvider):
|
| 33 |
+
"""
|
| 34 |
+
An in-memory OAuth provider for testing purposes.
|
| 35 |
+
It simulates the OAuth 2.0 flow locally without external calls.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
def __init__(
|
| 39 |
+
self,
|
| 40 |
+
issuer_url: AnyHttpUrl | str | None = None,
|
| 41 |
+
service_documentation_url: AnyHttpUrl | str | None = None,
|
| 42 |
+
client_registration_options: ClientRegistrationOptions | None = None,
|
| 43 |
+
revocation_options: RevocationOptions | None = None,
|
| 44 |
+
required_scopes: list[str] | None = None,
|
| 45 |
+
):
|
| 46 |
+
super().__init__(
|
| 47 |
+
issuer_url or "https://example.com",
|
| 48 |
+
service_documentation_url=service_documentation_url,
|
| 49 |
+
client_registration_options=client_registration_options,
|
| 50 |
+
revocation_options=revocation_options,
|
| 51 |
+
required_scopes=required_scopes,
|
| 52 |
+
)
|
| 53 |
+
self.clients: dict[str, OAuthClientInformationFull] = {}
|
| 54 |
+
self.auth_codes: dict[str, AuthorizationCode] = {}
|
| 55 |
+
self.access_tokens: dict[str, AccessToken] = {}
|
| 56 |
+
self.refresh_tokens: dict[str, RefreshToken] = {}
|
| 57 |
+
|
| 58 |
+
# For revoking associated tokens
|
| 59 |
+
self._access_to_refresh_map: dict[
|
| 60 |
+
str, str
|
| 61 |
+
] = {} # access_token_str -> refresh_token_str
|
| 62 |
+
self._refresh_to_access_map: dict[
|
| 63 |
+
str, str
|
| 64 |
+
] = {} # refresh_token_str -> access_token_str
|
| 65 |
+
|
| 66 |
+
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
| 67 |
+
return self.clients.get(client_id)
|
| 68 |
+
|
| 69 |
+
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
| 70 |
+
if client_info.client_id in self.clients:
|
| 71 |
+
# As per RFC 7591, if client_id is already known, it's an update.
|
| 72 |
+
# For this simple provider, we'll treat it as re-registration.
|
| 73 |
+
# A real provider might handle updates or raise errors for conflicts.
|
| 74 |
+
pass
|
| 75 |
+
self.clients[client_info.client_id] = client_info
|
| 76 |
+
|
| 77 |
+
async def authorize(
|
| 78 |
+
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
| 79 |
+
) -> str:
|
| 80 |
+
"""
|
| 81 |
+
Simulates user authorization and generates an authorization code.
|
| 82 |
+
Returns a redirect URI with the code and state.
|
| 83 |
+
"""
|
| 84 |
+
if client.client_id not in self.clients:
|
| 85 |
+
raise AuthorizeError(
|
| 86 |
+
error="unauthorized_client",
|
| 87 |
+
error_description=f"Client '{client.client_id}' not registered.",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# Validate redirect_uri (already validated by AuthorizationHandler, but good practice)
|
| 91 |
+
try:
|
| 92 |
+
# OAuthClientInformationFull should have a method like validate_redirect_uri
|
| 93 |
+
# For this test provider, we assume it's valid if it matches one in client_info
|
| 94 |
+
# The AuthorizationHandler already does robust validation using client.validate_redirect_uri
|
| 95 |
+
if params.redirect_uri not in client.redirect_uris:
|
| 96 |
+
# This check might be too simplistic if redirect_uris can be patterns
|
| 97 |
+
# or if params.redirect_uri is None and client has a default.
|
| 98 |
+
# However, the AuthorizationHandler handles the primary validation.
|
| 99 |
+
pass # Let's assume AuthorizationHandler did its job.
|
| 100 |
+
except Exception: # Replace with specific validation error if client.validate_redirect_uri existed
|
| 101 |
+
raise AuthorizeError(
|
| 102 |
+
error="invalid_request", error_description="Invalid redirect_uri."
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
auth_code_value = f"test_auth_code_{secrets.token_hex(16)}"
|
| 106 |
+
expires_at = time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS
|
| 107 |
+
|
| 108 |
+
# Ensure scopes are a list
|
| 109 |
+
scopes_list = params.scopes if params.scopes is not None else []
|
| 110 |
+
if client.scope: # Filter params.scopes against client's registered scopes
|
| 111 |
+
client_allowed_scopes = set(client.scope.split())
|
| 112 |
+
scopes_list = [s for s in scopes_list if s in client_allowed_scopes]
|
| 113 |
+
|
| 114 |
+
auth_code = AuthorizationCode(
|
| 115 |
+
code=auth_code_value,
|
| 116 |
+
client_id=client.client_id,
|
| 117 |
+
redirect_uri=params.redirect_uri,
|
| 118 |
+
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
| 119 |
+
scopes=scopes_list,
|
| 120 |
+
expires_at=expires_at,
|
| 121 |
+
code_challenge=params.code_challenge,
|
| 122 |
+
# code_challenge_method is assumed S256 by the framework
|
| 123 |
+
)
|
| 124 |
+
self.auth_codes[auth_code_value] = auth_code
|
| 125 |
+
|
| 126 |
+
return construct_redirect_uri(
|
| 127 |
+
str(params.redirect_uri), code=auth_code_value, state=params.state
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
async def load_authorization_code(
|
| 131 |
+
self, client: OAuthClientInformationFull, authorization_code: str
|
| 132 |
+
) -> AuthorizationCode | None:
|
| 133 |
+
auth_code_obj = self.auth_codes.get(authorization_code)
|
| 134 |
+
if auth_code_obj:
|
| 135 |
+
if auth_code_obj.client_id != client.client_id:
|
| 136 |
+
return None # Belongs to a different client
|
| 137 |
+
if auth_code_obj.expires_at < time.time():
|
| 138 |
+
del self.auth_codes[authorization_code] # Expired
|
| 139 |
+
return None
|
| 140 |
+
return auth_code_obj
|
| 141 |
+
return None
|
| 142 |
+
|
| 143 |
+
async def exchange_authorization_code(
|
| 144 |
+
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
| 145 |
+
) -> OAuthToken:
|
| 146 |
+
# Authorization code should have been validated (existence, expiry, client_id match)
|
| 147 |
+
# by the TokenHandler calling load_authorization_code before this.
|
| 148 |
+
# We might want to re-verify or simply trust it's valid.
|
| 149 |
+
|
| 150 |
+
if authorization_code.code not in self.auth_codes:
|
| 151 |
+
raise TokenError(
|
| 152 |
+
"invalid_grant", "Authorization code not found or already used."
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# Consume the auth code
|
| 156 |
+
del self.auth_codes[authorization_code.code]
|
| 157 |
+
|
| 158 |
+
access_token_value = f"test_access_token_{secrets.token_hex(32)}"
|
| 159 |
+
refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"
|
| 160 |
+
|
| 161 |
+
access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
|
| 162 |
+
|
| 163 |
+
# Refresh token expiry
|
| 164 |
+
refresh_token_expires_at = None
|
| 165 |
+
if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
|
| 166 |
+
refresh_token_expires_at = int(
|
| 167 |
+
time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
self.access_tokens[access_token_value] = AccessToken(
|
| 171 |
+
token=access_token_value,
|
| 172 |
+
client_id=client.client_id,
|
| 173 |
+
scopes=authorization_code.scopes,
|
| 174 |
+
expires_at=access_token_expires_at,
|
| 175 |
+
)
|
| 176 |
+
self.refresh_tokens[refresh_token_value] = RefreshToken(
|
| 177 |
+
token=refresh_token_value,
|
| 178 |
+
client_id=client.client_id,
|
| 179 |
+
scopes=authorization_code.scopes, # Refresh token inherits scopes
|
| 180 |
+
expires_at=refresh_token_expires_at,
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
self._access_to_refresh_map[access_token_value] = refresh_token_value
|
| 184 |
+
self._refresh_to_access_map[refresh_token_value] = access_token_value
|
| 185 |
+
|
| 186 |
+
return OAuthToken(
|
| 187 |
+
access_token=access_token_value,
|
| 188 |
+
token_type="bearer",
|
| 189 |
+
expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
|
| 190 |
+
refresh_token=refresh_token_value,
|
| 191 |
+
scope=" ".join(authorization_code.scopes),
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
async def load_refresh_token(
|
| 195 |
+
self, client: OAuthClientInformationFull, refresh_token: str
|
| 196 |
+
) -> RefreshToken | None:
|
| 197 |
+
token_obj = self.refresh_tokens.get(refresh_token)
|
| 198 |
+
if token_obj:
|
| 199 |
+
if token_obj.client_id != client.client_id:
|
| 200 |
+
return None # Belongs to different client
|
| 201 |
+
if token_obj.expires_at is not None and token_obj.expires_at < time.time():
|
| 202 |
+
self._revoke_internal(
|
| 203 |
+
refresh_token_str=token_obj.token
|
| 204 |
+
) # Clean up expired
|
| 205 |
+
return None
|
| 206 |
+
return token_obj
|
| 207 |
+
return None
|
| 208 |
+
|
| 209 |
+
async def exchange_refresh_token(
|
| 210 |
+
self,
|
| 211 |
+
client: OAuthClientInformationFull,
|
| 212 |
+
refresh_token: RefreshToken, # This is the RefreshToken object, already loaded
|
| 213 |
+
scopes: list[str], # Requested scopes for the new access token
|
| 214 |
+
) -> OAuthToken:
|
| 215 |
+
# Validate scopes: requested scopes must be a subset of original scopes
|
| 216 |
+
original_scopes = set(refresh_token.scopes)
|
| 217 |
+
requested_scopes = set(scopes)
|
| 218 |
+
if not requested_scopes.issubset(original_scopes):
|
| 219 |
+
raise TokenError(
|
| 220 |
+
"invalid_scope",
|
| 221 |
+
"Requested scopes exceed those authorized by the refresh token.",
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
# Invalidate old refresh token and its associated access token (rotation)
|
| 225 |
+
self._revoke_internal(refresh_token_str=refresh_token.token)
|
| 226 |
+
|
| 227 |
+
# Issue new tokens
|
| 228 |
+
new_access_token_value = f"test_access_token_{secrets.token_hex(32)}"
|
| 229 |
+
new_refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"
|
| 230 |
+
|
| 231 |
+
access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
|
| 232 |
+
|
| 233 |
+
# Refresh token expiry
|
| 234 |
+
refresh_token_expires_at = None
|
| 235 |
+
if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
|
| 236 |
+
refresh_token_expires_at = int(
|
| 237 |
+
time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
self.access_tokens[new_access_token_value] = AccessToken(
|
| 241 |
+
token=new_access_token_value,
|
| 242 |
+
client_id=client.client_id,
|
| 243 |
+
scopes=scopes, # Use newly requested (and validated) scopes
|
| 244 |
+
expires_at=access_token_expires_at,
|
| 245 |
+
)
|
| 246 |
+
self.refresh_tokens[new_refresh_token_value] = RefreshToken(
|
| 247 |
+
token=new_refresh_token_value,
|
| 248 |
+
client_id=client.client_id,
|
| 249 |
+
scopes=scopes, # New refresh token also gets these scopes
|
| 250 |
+
expires_at=refresh_token_expires_at,
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
self._access_to_refresh_map[new_access_token_value] = new_refresh_token_value
|
| 254 |
+
self._refresh_to_access_map[new_refresh_token_value] = new_access_token_value
|
| 255 |
+
|
| 256 |
+
return OAuthToken(
|
| 257 |
+
access_token=new_access_token_value,
|
| 258 |
+
token_type="bearer",
|
| 259 |
+
expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
|
| 260 |
+
refresh_token=new_refresh_token_value,
|
| 261 |
+
scope=" ".join(scopes),
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
async def load_access_token(self, token: str) -> AccessToken | None:
|
| 265 |
+
token_obj = self.access_tokens.get(token)
|
| 266 |
+
if token_obj:
|
| 267 |
+
if token_obj.expires_at is not None and token_obj.expires_at < time.time():
|
| 268 |
+
self._revoke_internal(
|
| 269 |
+
access_token_str=token_obj.token
|
| 270 |
+
) # Clean up expired
|
| 271 |
+
return None
|
| 272 |
+
return token_obj
|
| 273 |
+
return None
|
| 274 |
+
|
| 275 |
+
def _revoke_internal(
|
| 276 |
+
self, access_token_str: str | None = None, refresh_token_str: str | None = None
|
| 277 |
+
):
|
| 278 |
+
"""Internal helper to remove tokens and their associations."""
|
| 279 |
+
removed_access_token = None
|
| 280 |
+
removed_refresh_token = None
|
| 281 |
+
|
| 282 |
+
if access_token_str:
|
| 283 |
+
if access_token_str in self.access_tokens:
|
| 284 |
+
del self.access_tokens[access_token_str]
|
| 285 |
+
removed_access_token = access_token_str
|
| 286 |
+
|
| 287 |
+
# Get associated refresh token
|
| 288 |
+
associated_refresh = self._access_to_refresh_map.pop(access_token_str, None)
|
| 289 |
+
if associated_refresh:
|
| 290 |
+
if associated_refresh in self.refresh_tokens:
|
| 291 |
+
del self.refresh_tokens[associated_refresh]
|
| 292 |
+
removed_refresh_token = associated_refresh
|
| 293 |
+
self._refresh_to_access_map.pop(associated_refresh, None)
|
| 294 |
+
|
| 295 |
+
if refresh_token_str:
|
| 296 |
+
if refresh_token_str in self.refresh_tokens:
|
| 297 |
+
del self.refresh_tokens[refresh_token_str]
|
| 298 |
+
removed_refresh_token = refresh_token_str
|
| 299 |
+
|
| 300 |
+
# Get associated access token
|
| 301 |
+
associated_access = self._refresh_to_access_map.pop(refresh_token_str, None)
|
| 302 |
+
if associated_access:
|
| 303 |
+
if associated_access in self.access_tokens:
|
| 304 |
+
del self.access_tokens[associated_access]
|
| 305 |
+
removed_access_token = associated_access
|
| 306 |
+
self._access_to_refresh_map.pop(associated_access, None)
|
| 307 |
+
|
| 308 |
+
# Clean up any dangling references if one part of the pair was already gone
|
| 309 |
+
if removed_access_token and removed_access_token in self._access_to_refresh_map:
|
| 310 |
+
del self._access_to_refresh_map[removed_access_token]
|
| 311 |
+
if (
|
| 312 |
+
removed_refresh_token
|
| 313 |
+
and removed_refresh_token in self._refresh_to_access_map
|
| 314 |
+
):
|
| 315 |
+
del self._refresh_to_access_map[removed_refresh_token]
|
| 316 |
+
|
| 317 |
+
async def revoke_token(
|
| 318 |
+
self,
|
| 319 |
+
token: AccessToken | RefreshToken,
|
| 320 |
+
) -> None:
|
| 321 |
+
"""Revokes an access or refresh token and its counterpart."""
|
| 322 |
+
if isinstance(token, AccessToken):
|
| 323 |
+
self._revoke_internal(access_token_str=token.token)
|
| 324 |
+
elif isinstance(token, RefreshToken):
|
| 325 |
+
self._revoke_internal(refresh_token_str=token.token)
|
| 326 |
+
# If token is not found or already revoked, _revoke_internal does nothing, which is correct.
|
src/fastmcp/server/http.py
CHANGED
|
@@ -10,14 +10,7 @@ from mcp.server.auth.middleware.bearer_auth import (
|
|
| 10 |
BearerAuthBackend,
|
| 11 |
RequireAuthMiddleware,
|
| 12 |
)
|
| 13 |
-
from mcp.server.auth.provider import (
|
| 14 |
-
AccessTokenT,
|
| 15 |
-
AuthorizationCodeT,
|
| 16 |
-
OAuthAuthorizationServerProvider,
|
| 17 |
-
RefreshTokenT,
|
| 18 |
-
)
|
| 19 |
from mcp.server.auth.routes import create_auth_routes
|
| 20 |
-
from mcp.server.auth.settings import AuthSettings
|
| 21 |
from mcp.server.lowlevel.server import LifespanResultT
|
| 22 |
from mcp.server.sse import SseServerTransport
|
| 23 |
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
@@ -29,6 +22,7 @@ from starlette.responses import Response
|
|
| 29 |
from starlette.routing import BaseRoute, Mount, Route
|
| 30 |
from starlette.types import Lifespan, Receive, Scope, Send
|
| 31 |
|
|
|
|
| 32 |
from fastmcp.utilities.logging import get_logger
|
| 33 |
|
| 34 |
if TYPE_CHECKING:
|
|
@@ -75,17 +69,12 @@ class RequestContextMiddleware:
|
|
| 75 |
|
| 76 |
|
| 77 |
def setup_auth_middleware_and_routes(
|
| 78 |
-
|
| 79 |
-
AuthorizationCodeT, RefreshTokenT, AccessTokenT
|
| 80 |
-
]
|
| 81 |
-
| None,
|
| 82 |
-
auth_settings: AuthSettings | None,
|
| 83 |
) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
|
| 84 |
"""Set up authentication middleware and routes if auth is enabled.
|
| 85 |
|
| 86 |
Args:
|
| 87 |
-
|
| 88 |
-
auth_settings: The auth settings
|
| 89 |
|
| 90 |
Returns:
|
| 91 |
Tuple of (middleware, auth_routes, required_scopes)
|
|
@@ -94,31 +83,25 @@ def setup_auth_middleware_and_routes(
|
|
| 94 |
auth_routes: list[BaseRoute] = []
|
| 95 |
required_scopes: list[str] = []
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
auth_routes.extend(
|
| 114 |
-
create_auth_routes(
|
| 115 |
-
provider=auth_server_provider,
|
| 116 |
-
issuer_url=auth_settings.issuer_url,
|
| 117 |
-
service_documentation_url=auth_settings.service_documentation_url,
|
| 118 |
-
client_registration_options=auth_settings.client_registration_options,
|
| 119 |
-
revocation_options=auth_settings.revocation_options,
|
| 120 |
-
)
|
| 121 |
)
|
|
|
|
| 122 |
|
| 123 |
return middleware, auth_routes, required_scopes
|
| 124 |
|
|
@@ -155,11 +138,7 @@ def create_sse_app(
|
|
| 155 |
server: FastMCP[LifespanResultT],
|
| 156 |
message_path: str,
|
| 157 |
sse_path: str,
|
| 158 |
-
|
| 159 |
-
AuthorizationCodeT, RefreshTokenT, AccessTokenT
|
| 160 |
-
]
|
| 161 |
-
| None = None,
|
| 162 |
-
auth_settings: AuthSettings | None = None,
|
| 163 |
debug: bool = False,
|
| 164 |
routes: list[BaseRoute] | None = None,
|
| 165 |
middleware: list[Middleware] | None = None,
|
|
@@ -170,8 +149,7 @@ def create_sse_app(
|
|
| 170 |
server: The FastMCP server instance
|
| 171 |
message_path: Path for SSE messages
|
| 172 |
sse_path: Path for SSE connections
|
| 173 |
-
|
| 174 |
-
auth_settings: Optional auth settings
|
| 175 |
debug: Whether to enable debug mode
|
| 176 |
routes: Optional list of custom routes
|
| 177 |
middleware: Optional list of middleware
|
|
@@ -196,15 +174,15 @@ def create_sse_app(
|
|
| 196 |
return Response()
|
| 197 |
|
| 198 |
# Get auth middleware and routes
|
| 199 |
-
auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
|
| 200 |
-
auth_server_provider, auth_settings
|
| 201 |
-
)
|
| 202 |
-
|
| 203 |
-
server_routes.extend(auth_routes)
|
| 204 |
-
server_middleware.extend(auth_middleware)
|
| 205 |
|
| 206 |
# Add SSE routes with or without auth
|
| 207 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
# Auth is enabled, wrap endpoints with RequireAuthMiddleware
|
| 209 |
server_routes.append(
|
| 210 |
Route(
|
|
@@ -264,11 +242,7 @@ def create_streamable_http_app(
|
|
| 264 |
server: FastMCP[LifespanResultT],
|
| 265 |
streamable_http_path: str,
|
| 266 |
event_store: None = None,
|
| 267 |
-
|
| 268 |
-
AuthorizationCodeT, RefreshTokenT, AccessTokenT
|
| 269 |
-
]
|
| 270 |
-
| None = None,
|
| 271 |
-
auth_settings: AuthSettings | None = None,
|
| 272 |
json_response: bool = False,
|
| 273 |
stateless_http: bool = False,
|
| 274 |
debug: bool = False,
|
|
@@ -281,8 +255,7 @@ def create_streamable_http_app(
|
|
| 281 |
server: The FastMCP server instance
|
| 282 |
streamable_http_path: Path for StreamableHTTP connections
|
| 283 |
event_store: Optional event store for session management
|
| 284 |
-
|
| 285 |
-
auth_settings: Optional auth settings
|
| 286 |
json_response: Whether to use JSON response format
|
| 287 |
stateless_http: Whether to use stateless mode (new transport per request)
|
| 288 |
debug: Whether to enable debug mode
|
|
@@ -331,16 +304,15 @@ def create_streamable_http_app(
|
|
| 331 |
# Re-raise other RuntimeErrors if they don't match the specific message
|
| 332 |
raise
|
| 333 |
|
| 334 |
-
#
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
|
|
|
| 338 |
|
| 339 |
-
|
| 340 |
-
|
| 341 |
|
| 342 |
-
# Add StreamableHTTP routes with or without auth
|
| 343 |
-
if auth_server_provider:
|
| 344 |
# Auth is enabled, wrap endpoint with RequireAuthMiddleware
|
| 345 |
server_routes.append(
|
| 346 |
Mount(
|
|
|
|
| 10 |
BearerAuthBackend,
|
| 11 |
RequireAuthMiddleware,
|
| 12 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from mcp.server.auth.routes import create_auth_routes
|
|
|
|
| 14 |
from mcp.server.lowlevel.server import LifespanResultT
|
| 15 |
from mcp.server.sse import SseServerTransport
|
| 16 |
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
|
|
| 22 |
from starlette.routing import BaseRoute, Mount, Route
|
| 23 |
from starlette.types import Lifespan, Receive, Scope, Send
|
| 24 |
|
| 25 |
+
from fastmcp.server.auth.auth import OAuthProvider
|
| 26 |
from fastmcp.utilities.logging import get_logger
|
| 27 |
|
| 28 |
if TYPE_CHECKING:
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
def setup_auth_middleware_and_routes(
|
| 72 |
+
auth: OAuthProvider,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
|
| 74 |
"""Set up authentication middleware and routes if auth is enabled.
|
| 75 |
|
| 76 |
Args:
|
| 77 |
+
auth: The OAuthProvider authorization server provider
|
|
|
|
| 78 |
|
| 79 |
Returns:
|
| 80 |
Tuple of (middleware, auth_routes, required_scopes)
|
|
|
|
| 83 |
auth_routes: list[BaseRoute] = []
|
| 84 |
required_scopes: list[str] = []
|
| 85 |
|
| 86 |
+
middleware = [
|
| 87 |
+
Middleware(
|
| 88 |
+
AuthenticationMiddleware,
|
| 89 |
+
backend=BearerAuthBackend(provider=auth),
|
| 90 |
+
),
|
| 91 |
+
Middleware(AuthContextMiddleware),
|
| 92 |
+
]
|
| 93 |
|
| 94 |
+
required_scopes = auth.settings.required_scopes or []
|
| 95 |
+
|
| 96 |
+
auth_routes.extend(
|
| 97 |
+
create_auth_routes(
|
| 98 |
+
provider=auth,
|
| 99 |
+
issuer_url=auth.settings.issuer_url,
|
| 100 |
+
service_documentation_url=auth.settings.service_documentation_url,
|
| 101 |
+
client_registration_options=auth.settings.client_registration_options,
|
| 102 |
+
revocation_options=auth.settings.revocation_options,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
)
|
| 104 |
+
)
|
| 105 |
|
| 106 |
return middleware, auth_routes, required_scopes
|
| 107 |
|
|
|
|
| 138 |
server: FastMCP[LifespanResultT],
|
| 139 |
message_path: str,
|
| 140 |
sse_path: str,
|
| 141 |
+
auth: OAuthProvider | None = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
debug: bool = False,
|
| 143 |
routes: list[BaseRoute] | None = None,
|
| 144 |
middleware: list[Middleware] | None = None,
|
|
|
|
| 149 |
server: The FastMCP server instance
|
| 150 |
message_path: Path for SSE messages
|
| 151 |
sse_path: Path for SSE connections
|
| 152 |
+
auth: Optional auth provider
|
|
|
|
| 153 |
debug: Whether to enable debug mode
|
| 154 |
routes: Optional list of custom routes
|
| 155 |
middleware: Optional list of middleware
|
|
|
|
| 174 |
return Response()
|
| 175 |
|
| 176 |
# Get auth middleware and routes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
# Add SSE routes with or without auth
|
| 179 |
+
if auth:
|
| 180 |
+
auth_middleware, auth_routes, required_scopes = (
|
| 181 |
+
setup_auth_middleware_and_routes(auth)
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
server_routes.extend(auth_routes)
|
| 185 |
+
server_middleware.extend(auth_middleware)
|
| 186 |
# Auth is enabled, wrap endpoints with RequireAuthMiddleware
|
| 187 |
server_routes.append(
|
| 188 |
Route(
|
|
|
|
| 242 |
server: FastMCP[LifespanResultT],
|
| 243 |
streamable_http_path: str,
|
| 244 |
event_store: None = None,
|
| 245 |
+
auth: OAuthProvider | None = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
json_response: bool = False,
|
| 247 |
stateless_http: bool = False,
|
| 248 |
debug: bool = False,
|
|
|
|
| 255 |
server: The FastMCP server instance
|
| 256 |
streamable_http_path: Path for StreamableHTTP connections
|
| 257 |
event_store: Optional event store for session management
|
| 258 |
+
auth: Optional auth provider
|
|
|
|
| 259 |
json_response: Whether to use JSON response format
|
| 260 |
stateless_http: Whether to use stateless mode (new transport per request)
|
| 261 |
debug: Whether to enable debug mode
|
|
|
|
| 304 |
# Re-raise other RuntimeErrors if they don't match the specific message
|
| 305 |
raise
|
| 306 |
|
| 307 |
+
# Add StreamableHTTP routes with or without auth
|
| 308 |
+
if auth:
|
| 309 |
+
auth_middleware, auth_routes, required_scopes = (
|
| 310 |
+
setup_auth_middleware_and_routes(auth)
|
| 311 |
+
)
|
| 312 |
|
| 313 |
+
server_routes.extend(auth_routes)
|
| 314 |
+
server_middleware.extend(auth_middleware)
|
| 315 |
|
|
|
|
|
|
|
| 316 |
# Auth is enabled, wrap endpoint with RequireAuthMiddleware
|
| 317 |
server_routes.append(
|
| 318 |
Mount(
|
src/fastmcp/server/server.py
CHANGED
|
@@ -18,7 +18,6 @@ from typing import TYPE_CHECKING, Any, Generic, Literal
|
|
| 18 |
import anyio
|
| 19 |
import httpx
|
| 20 |
import uvicorn
|
| 21 |
-
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
|
| 22 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
| 23 |
from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
|
| 24 |
from mcp.server.lowlevel.server import Server as MCPServer
|
|
@@ -48,6 +47,7 @@ from fastmcp.prompts import Prompt, PromptManager
|
|
| 48 |
from fastmcp.prompts.prompt import PromptResult
|
| 49 |
from fastmcp.resources import Resource, ResourceManager
|
| 50 |
from fastmcp.resources.template import ResourceTemplate
|
|
|
|
| 51 |
from fastmcp.server.http import (
|
| 52 |
StarletteWithLifespan,
|
| 53 |
create_sse_app,
|
|
@@ -110,8 +110,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 110 |
self,
|
| 111 |
name: str | None = None,
|
| 112 |
instructions: str | None = None,
|
| 113 |
-
|
| 114 |
-
| None = None,
|
| 115 |
lifespan: (
|
| 116 |
Callable[
|
| 117 |
[FastMCP[LifespanResultT]],
|
|
@@ -186,13 +185,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 186 |
lifespan=_lifespan_wrapper(self, lifespan),
|
| 187 |
)
|
| 188 |
|
| 189 |
-
|
| 190 |
-
# TODO: after we support separate authorization servers (see
|
| 191 |
-
raise ValueError(
|
| 192 |
-
"settings.auth must be specified if and only if auth_server_provider "
|
| 193 |
-
"is specified"
|
| 194 |
-
)
|
| 195 |
-
self._auth_server_provider = auth_server_provider
|
| 196 |
|
| 197 |
# Set up MCP protocol handlers
|
| 198 |
self._setup_handlers()
|
|
@@ -903,8 +896,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 903 |
server=self,
|
| 904 |
message_path=message_path or self.settings.message_path,
|
| 905 |
sse_path=path or self.settings.sse_path,
|
| 906 |
-
|
| 907 |
-
auth_settings=self.settings.auth,
|
| 908 |
debug=self.settings.debug,
|
| 909 |
middleware=middleware,
|
| 910 |
)
|
|
@@ -951,8 +943,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 951 |
server=self,
|
| 952 |
streamable_http_path=path or self.settings.streamable_http_path,
|
| 953 |
event_store=None,
|
| 954 |
-
|
| 955 |
-
auth_settings=self.settings.auth,
|
| 956 |
json_response=self.settings.json_response,
|
| 957 |
stateless_http=self.settings.stateless_http,
|
| 958 |
debug=self.settings.debug,
|
|
@@ -963,8 +954,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 963 |
server=self,
|
| 964 |
message_path=self.settings.message_path,
|
| 965 |
sse_path=path or self.settings.sse_path,
|
| 966 |
-
|
| 967 |
-
auth_settings=self.settings.auth,
|
| 968 |
debug=self.settings.debug,
|
| 969 |
middleware=middleware,
|
| 970 |
)
|
|
|
|
| 18 |
import anyio
|
| 19 |
import httpx
|
| 20 |
import uvicorn
|
|
|
|
| 21 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
| 22 |
from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
|
| 23 |
from mcp.server.lowlevel.server import Server as MCPServer
|
|
|
|
| 47 |
from fastmcp.prompts.prompt import PromptResult
|
| 48 |
from fastmcp.resources import Resource, ResourceManager
|
| 49 |
from fastmcp.resources.template import ResourceTemplate
|
| 50 |
+
from fastmcp.server.auth.auth import OAuthProvider
|
| 51 |
from fastmcp.server.http import (
|
| 52 |
StarletteWithLifespan,
|
| 53 |
create_sse_app,
|
|
|
|
| 110 |
self,
|
| 111 |
name: str | None = None,
|
| 112 |
instructions: str | None = None,
|
| 113 |
+
auth: OAuthProvider | None = None,
|
|
|
|
| 114 |
lifespan: (
|
| 115 |
Callable[
|
| 116 |
[FastMCP[LifespanResultT]],
|
|
|
|
| 185 |
lifespan=_lifespan_wrapper(self, lifespan),
|
| 186 |
)
|
| 187 |
|
| 188 |
+
self.auth = auth
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
# Set up MCP protocol handlers
|
| 191 |
self._setup_handlers()
|
|
|
|
| 896 |
server=self,
|
| 897 |
message_path=message_path or self.settings.message_path,
|
| 898 |
sse_path=path or self.settings.sse_path,
|
| 899 |
+
auth=self.auth,
|
|
|
|
| 900 |
debug=self.settings.debug,
|
| 901 |
middleware=middleware,
|
| 902 |
)
|
|
|
|
| 943 |
server=self,
|
| 944 |
streamable_http_path=path or self.settings.streamable_http_path,
|
| 945 |
event_store=None,
|
| 946 |
+
auth=self.auth,
|
|
|
|
| 947 |
json_response=self.settings.json_response,
|
| 948 |
stateless_http=self.settings.stateless_http,
|
| 949 |
debug=self.settings.debug,
|
|
|
|
| 954 |
server=self,
|
| 955 |
message_path=self.settings.message_path,
|
| 956 |
sse_path=path or self.settings.sse_path,
|
| 957 |
+
auth=self.auth,
|
|
|
|
| 958 |
debug=self.settings.debug,
|
| 959 |
middleware=middleware,
|
| 960 |
)
|
src/fastmcp/settings.py
CHANGED
|
@@ -4,7 +4,6 @@ import inspect
|
|
| 4 |
from pathlib import Path
|
| 5 |
from typing import Annotated, Literal
|
| 6 |
|
| 7 |
-
from mcp.server.auth.settings import AuthSettings
|
| 8 |
from pydantic import Field, model_validator
|
| 9 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 10 |
from typing_extensions import Self
|
|
@@ -171,8 +170,6 @@ class ServerSettings(BaseSettings):
|
|
| 171 |
# cache settings (for checking mounted servers)
|
| 172 |
cache_expiration_seconds: float = 0
|
| 173 |
|
| 174 |
-
auth: AuthSettings | None = None
|
| 175 |
-
|
| 176 |
# StreamableHTTP settings
|
| 177 |
json_response: bool = False
|
| 178 |
stateless_http: bool = (
|
|
|
|
| 4 |
from pathlib import Path
|
| 5 |
from typing import Annotated, Literal
|
| 6 |
|
|
|
|
| 7 |
from pydantic import Field, model_validator
|
| 8 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 9 |
from typing_extensions import Self
|
|
|
|
| 170 |
# cache settings (for checking mounted servers)
|
| 171 |
cache_expiration_seconds: float = 0
|
| 172 |
|
|
|
|
|
|
|
| 173 |
# StreamableHTTP settings
|
| 174 |
json_response: bool = False
|
| 175 |
stateless_http: bool = (
|
src/fastmcp/utilities/tests.py
CHANGED
|
@@ -94,7 +94,7 @@ def run_server_in_process(
|
|
| 94 |
proc.start()
|
| 95 |
|
| 96 |
# Wait for server to be running
|
| 97 |
-
max_attempts =
|
| 98 |
attempt = 0
|
| 99 |
while attempt < max_attempts and proc.is_alive():
|
| 100 |
try:
|
|
@@ -102,7 +102,10 @@ def run_server_in_process(
|
|
| 102 |
s.connect((host, port))
|
| 103 |
break
|
| 104 |
except ConnectionRefusedError:
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
| 106 |
attempt += 1
|
| 107 |
else:
|
| 108 |
raise RuntimeError(f"Server failed to start after {max_attempts} attempts")
|
|
|
|
| 94 |
proc.start()
|
| 95 |
|
| 96 |
# Wait for server to be running
|
| 97 |
+
max_attempts = 10
|
| 98 |
attempt = 0
|
| 99 |
while attempt < max_attempts and proc.is_alive():
|
| 100 |
try:
|
|
|
|
| 102 |
s.connect((host, port))
|
| 103 |
break
|
| 104 |
except ConnectionRefusedError:
|
| 105 |
+
if attempt < 3:
|
| 106 |
+
time.sleep(0.01)
|
| 107 |
+
else:
|
| 108 |
+
time.sleep(0.1)
|
| 109 |
attempt += 1
|
| 110 |
else:
|
| 111 |
raise RuntimeError(f"Server failed to start after {max_attempts} attempts")
|