Skip to content

Commit e6ffb99

Browse files
committed
wip
1 parent 775f879 commit e6ffb99

File tree

19 files changed

+1956
-11
lines changed

19 files changed

+1956
-11
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies = [
3030
"sse-starlette>=1.6.1",
3131
"pydantic-settings>=2.5.2",
3232
"uvicorn>=0.23.1",
33+
"fastapi",
3334
]
3435

3536
[project.optional-dependencies]
@@ -46,7 +47,7 @@ dev-dependencies = [
4647
"pytest>=8.3.4",
4748
"ruff>=0.8.5",
4849
"trio>=0.26.2",
49-
"pytest-flakefinder>=1.1.0",
50+
"pytest-flakefinder==1.1.0",
5051
"pytest-xdist>=3.6.1",
5152
]
5253

src/mcp/server/auth/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
MCP OAuth server authorization components.
3+
"""

src/mcp/server/auth/errors.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""
2+
OAuth error classes for MCP authorization.
3+
4+
Corresponds to TypeScript file: src/server/auth/errors.ts
5+
"""
6+
7+
from typing import Dict, Optional, Any
8+
9+
10+
class OAuthError(Exception):
11+
"""
12+
Base class for all OAuth errors.
13+
14+
Corresponds to OAuthError in src/server/auth/errors.ts
15+
"""
16+
error_code: str = "server_error"
17+
18+
def __init__(self, message: str):
19+
super().__init__(message)
20+
self.message = message
21+
22+
def to_response_object(self) -> Dict[str, str]:
23+
"""Convert error to JSON response object."""
24+
return {
25+
"error": self.error_code,
26+
"error_description": self.message
27+
}
28+
29+
30+
class ServerError(OAuthError):
31+
"""
32+
Server error.
33+
34+
Corresponds to ServerError in src/server/auth/errors.ts
35+
"""
36+
error_code = "server_error"
37+
38+
39+
class InvalidRequestError(OAuthError):
40+
"""
41+
Invalid request error.
42+
43+
Corresponds to InvalidRequestError in src/server/auth/errors.ts
44+
"""
45+
error_code = "invalid_request"
46+
47+
48+
class InvalidClientError(OAuthError):
49+
"""
50+
Invalid client error.
51+
52+
Corresponds to InvalidClientError in src/server/auth/errors.ts
53+
"""
54+
error_code = "invalid_client"
55+
56+
57+
class InvalidGrantError(OAuthError):
58+
"""
59+
Invalid grant error.
60+
61+
Corresponds to InvalidGrantError in src/server/auth/errors.ts
62+
"""
63+
error_code = "invalid_grant"
64+
65+
66+
class UnauthorizedClientError(OAuthError):
67+
"""
68+
Unauthorized client error.
69+
70+
Corresponds to UnauthorizedClientError in src/server/auth/errors.ts
71+
"""
72+
error_code = "unauthorized_client"
73+
74+
75+
class UnsupportedGrantTypeError(OAuthError):
76+
"""
77+
Unsupported grant type error.
78+
79+
Corresponds to UnsupportedGrantTypeError in src/server/auth/errors.ts
80+
"""
81+
error_code = "unsupported_grant_type"
82+
83+
84+
class UnsupportedResponseTypeError(OAuthError):
85+
"""
86+
Unsupported response type error.
87+
88+
Corresponds to UnsupportedResponseTypeError in src/server/auth/errors.ts
89+
"""
90+
error_code = "unsupported_response_type"
91+
92+
93+
class InvalidScopeError(OAuthError):
94+
"""
95+
Invalid scope error.
96+
97+
Corresponds to InvalidScopeError in src/server/auth/errors.ts
98+
"""
99+
error_code = "invalid_scope"
100+
101+
102+
class AccessDeniedError(OAuthError):
103+
"""
104+
Access denied error.
105+
106+
Corresponds to AccessDeniedError in src/server/auth/errors.ts
107+
"""
108+
error_code = "access_denied"
109+
110+
111+
class TemporarilyUnavailableError(OAuthError):
112+
"""
113+
Temporarily unavailable error.
114+
115+
Corresponds to TemporarilyUnavailableError in src/server/auth/errors.ts
116+
"""
117+
error_code = "temporarily_unavailable"
118+
119+
120+
class InvalidTokenError(OAuthError):
121+
"""
122+
Invalid token error.
123+
124+
Corresponds to InvalidTokenError in src/server/auth/errors.ts
125+
"""
126+
error_code = "invalid_token"
127+
128+
129+
class InsufficientScopeError(OAuthError):
130+
"""
131+
Insufficient scope error.
132+
133+
Corresponds to InsufficientScopeError in src/server/auth/errors.ts
134+
"""
135+
error_code = "insufficient_scope"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
Request handlers for MCP authorization endpoints.
3+
"""
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""
2+
Handler for OAuth 2.0 Authorization endpoint.
3+
4+
Corresponds to TypeScript file: src/server/auth/handlers/authorize.ts
5+
"""
6+
7+
import re
8+
from urllib.parse import urlparse, urlunparse, urlencode
9+
from typing import Any, Callable, Dict, List, Literal, Optional
10+
from urllib.parse import urlencode, parse_qs
11+
12+
from fastapi import Request, Response
13+
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, ValidationError
14+
from pydantic_core import Url
15+
from starlette.responses import JSONResponse, RedirectResponse
16+
17+
from mcp.server.auth.errors import (
18+
InvalidClientError,
19+
InvalidRequestError,
20+
UnsupportedResponseTypeError,
21+
ServerError,
22+
OAuthError,
23+
)
24+
from mcp.server.auth.provider import AuthorizationParams, OAuthServerProvider
25+
from mcp.shared.auth import OAuthClientInformationFull
26+
27+
28+
class AuthorizationRequest(BaseModel):
29+
"""
30+
Model for the authorization request parameters.
31+
32+
Corresponds to request schema in authorizationHandler in src/server/auth/handlers/authorize.ts
33+
"""
34+
client_id: str = Field(..., description="The client ID")
35+
redirect_uri: AnyHttpUrl | None = Field(..., description="URL to redirect to after authorization")
36+
37+
response_type: Literal["code"] = Field(..., description="Must be 'code' for authorization code flow")
38+
code_challenge: str = Field(..., description="PKCE code challenge")
39+
code_challenge_method: Literal["S256"] = Field("S256", description="PKCE code challenge method")
40+
state: Optional[str] = Field(None, description="Optional state parameter")
41+
scope: Optional[str] = Field(None, description="Optional scope parameter")
42+
43+
class Config:
44+
extra = "ignore"
45+
46+
def validate_scope(requested_scope: str | None, client: OAuthClientInformationFull) -> list[str] | None:
47+
if requested_scope is None:
48+
return None
49+
requested_scopes = requested_scope.split(" ")
50+
allowed_scopes = [] if client.scope is None else client.scope.split(" ")
51+
for scope in requested_scopes:
52+
if scope not in allowed_scopes:
53+
raise InvalidRequestError(f"Client was not registered with scope {scope}")
54+
return requested_scopes
55+
56+
def validate_redirect_uri(auth_request: AuthorizationRequest, client: OAuthClientInformationFull) -> AnyHttpUrl:
57+
if auth_request.redirect_uri is not None:
58+
# Validate redirect_uri against client's registered redirect URIs
59+
if auth_request.redirect_uri not in client.redirect_uris:
60+
raise InvalidRequestError(
61+
f"Redirect URI '{auth_request.redirect_uri}' not registered for client"
62+
)
63+
return auth_request.redirect_uri
64+
elif len(client.redirect_uris) == 1:
65+
return client.redirect_uris[0]
66+
else:
67+
raise InvalidRequestError("redirect_uri must be specified when client has multiple registered URIs")
68+
69+
def create_authorization_handler(provider: OAuthServerProvider) -> Callable:
70+
"""
71+
Create a handler for the OAuth 2.0 Authorization endpoint.
72+
73+
Corresponds to authorizationHandler in src/server/auth/handlers/authorize.ts
74+
75+
"""
76+
77+
async def authorization_handler(request: Request) -> Response:
78+
"""
79+
Handler for the OAuth 2.0 Authorization endpoint.
80+
"""
81+
# Validate request parameters
82+
try:
83+
if request.method == "GET":
84+
auth_request = AuthorizationRequest.model_validate(request.query_params)
85+
else:
86+
auth_request = AuthorizationRequest.model_validate_json(await request.body())
87+
except ValidationError as e:
88+
raise InvalidRequestError(str(e))
89+
90+
# Get client information
91+
try:
92+
client = await provider.clients_store.get_client(auth_request.client_id)
93+
except OAuthError as e:
94+
# TODO: proper error rendering
95+
raise InvalidClientError(str(e))
96+
97+
if not client:
98+
raise InvalidClientError(f"Client ID '{auth_request.client_id}' not found")
99+
100+
101+
# do validation which is dependent on the client configuration
102+
redirect_uri = validate_redirect_uri(auth_request, client)
103+
scopes = validate_scope(auth_request.scope, client)
104+
105+
auth_params = AuthorizationParams(
106+
state=auth_request.state,
107+
scopes=scopes,
108+
code_challenge=auth_request.code_challenge,
109+
redirect_uri=redirect_uri,
110+
)
111+
112+
response = RedirectResponse(url="", status_code=302, headers={"Cache-Control": "no-store"})
113+
114+
try:
115+
# Let the provider handle the authorization flow
116+
await provider.authorize(client, auth_params, response)
117+
118+
return response
119+
except Exception as e:
120+
return RedirectResponse(
121+
url=create_error_redirect(redirect_uri, e, auth_request.state),
122+
status_code=302,
123+
headers={"Cache-Control": "no-store"},
124+
)
125+
126+
return authorization_handler
127+
128+
def create_error_redirect(redirect_uri: AnyUrl, error: Exception, state: Optional[str]) -> str:
129+
parsed_uri = urlparse(str(redirect_uri))
130+
if isinstance(error, OAuthError):
131+
query_params = {
132+
"error": error.error_code,
133+
"error_description": str(error)
134+
}
135+
else:
136+
query_params = {
137+
"error": "internal_error",
138+
"error_description": "An unknown error occurred"
139+
}
140+
# TODO: should we add error_uri?
141+
# if error.error_uri:
142+
# query_params["error_uri"] = str(error.error_uri)
143+
if state:
144+
query_params["state"] = state
145+
146+
new_query = urlencode(query_params)
147+
if parsed_uri.query:
148+
new_query = f"{parsed_uri.query}&{new_query}"
149+
150+
return urlunparse(parsed_uri._replace(query=new_query))
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""
2+
Handler for OAuth 2.0 Authorization Server Metadata.
3+
4+
Corresponds to TypeScript file: src/server/auth/handlers/metadata.ts
5+
"""
6+
7+
from typing import Any, Callable, Dict, Optional
8+
from fastapi import Request, Response
9+
from starlette.responses import JSONResponse
10+
11+
12+
def create_metadata_handler(metadata: Dict[str, Any]) -> Callable:
13+
"""
14+
Create a handler for OAuth 2.0 Authorization Server Metadata.
15+
16+
Corresponds to metadataHandler in src/server/auth/handlers/metadata.ts
17+
18+
Args:
19+
metadata: The metadata to return in the response
20+
21+
Returns:
22+
A FastAPI route handler function
23+
"""
24+
25+
async def metadata_handler(request: Request) -> Response:
26+
"""
27+
Handler for the OAuth 2.0 Authorization Server Metadata endpoint.
28+
29+
Args:
30+
request: The FastAPI request
31+
32+
Returns:
33+
JSON response with the authorization server metadata
34+
"""
35+
# Remove any None values from metadata
36+
clean_metadata = {k: v for k, v in metadata.items() if v is not None}
37+
38+
return JSONResponse(
39+
content=clean_metadata,
40+
headers={"Cache-Control": "public, max-age=3600"} # Cache for 1 hour
41+
)
42+
43+
return metadata_handler

0 commit comments

Comments
 (0)