Skip to content

Commit 21c974c

Browse files
committed
first cut at a per-client consent flow
1 parent 0eef578 commit 21c974c

File tree

5 files changed

+273
-0
lines changed

5 files changed

+273
-0
lines changed

examples/servers/simple-auth/mcp_simple_auth/server.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ def __init__(self, settings: ServerSettings):
7373
# Store GitHub tokens with MCP tokens using the format:
7474
# {"mcp_token": "github_token"}
7575
self.token_mapping: dict[str, str] = {}
76+
# Track which clients have been granted consent
77+
self.client_consent: dict[str, bool] = {}
7678

7779
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
7880
"""Get OAuth client information."""
@@ -81,6 +83,14 @@ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
8183
async def register_client(self, client_info: OAuthClientInformationFull):
8284
"""Register a new OAuth client."""
8385
self.clients[client_info.client_id] = client_info
86+
87+
async def has_client_consent(self, client: OAuthClientInformationFull) -> bool:
88+
"""Check if a client has already provided consent."""
89+
return self.client_consent.get(client.client_id, False)
90+
91+
async def grant_client_consent(self, client: OAuthClientInformationFull) -> None:
92+
"""Grant consent for a client."""
93+
self.client_consent[client.client_id] = True
8494

8595
async def authorize(
8696
self, client: OAuthClientInformationFull, params: AuthorizationParams

src/mcp/server/auth/handlers/authorize.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
from dataclasses import dataclass
33
from typing import Any, Literal
4+
from urllib.parse import urlencode
45

56
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, RootModel, ValidationError
67
from starlette.datastructures import FormData, QueryParams
@@ -218,6 +219,26 @@ async def error_response(
218219
)
219220

220221
try:
222+
# Check if client has already consented
223+
has_consent = await self.provider.has_client_consent(client)
224+
225+
if not has_consent:
226+
# Redirect to consent page with necessary parameters
227+
consent_url = "/consent" + "?" + urlencode({
228+
"client_id": auth_request.client_id,
229+
"redirect_uri": str(redirect_uri),
230+
"state": state or "",
231+
"scopes": " ".join(scopes) if scopes else "",
232+
"code_challenge": auth_request.code_challenge,
233+
"response_type": auth_request.response_type,
234+
})
235+
236+
return RedirectResponse(
237+
url=consent_url,
238+
status_code=302,
239+
headers={"Cache-Control": "no-store"},
240+
)
241+
221242
# Let the provider pick the next URI to redirect to
222243
return RedirectResponse(
223244
url=await self.provider.authorize(
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
from dataclasses import dataclass
2+
from typing import Any
3+
from urllib.parse import urlencode
4+
5+
from starlette.requests import Request
6+
from starlette.responses import HTMLResponse, RedirectResponse, Response
7+
8+
from mcp.server.auth.handlers.authorize import AuthorizationHandler
9+
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
10+
11+
12+
@dataclass
13+
class ConsentHandler:
14+
provider: OAuthAuthorizationServerProvider[Any, Any, Any]
15+
16+
async def handle(self, request: Request) -> Response:
17+
# This handles both showing the consent form (GET) and processing consent (POST)
18+
19+
if request.method == "GET":
20+
# Show consent form
21+
return await self._show_consent_form(request)
22+
elif request.method == "POST":
23+
# Process consent
24+
return await self._process_consent(request)
25+
else:
26+
return HTMLResponse(status_code=405, content="Method not allowed")
27+
28+
async def _show_consent_form(self, request: Request) -> HTMLResponse:
29+
client_id = request.query_params.get("client_id", "")
30+
redirect_uri = request.query_params.get("redirect_uri", "")
31+
state = request.query_params.get("state", "")
32+
scopes = request.query_params.get("scopes", "")
33+
code_challenge = request.query_params.get("code_challenge", "")
34+
response_type = request.query_params.get("response_type", "")
35+
36+
# TODO: get this passed in
37+
target_url = "/consent"
38+
39+
# Create a simple consent form
40+
41+
html_content = f"""
42+
<!DOCTYPE html>
43+
<html>
44+
<head>
45+
<title>Authorization Required</title>
46+
<meta charset="utf-8">
47+
<meta name="viewport" content="width=device-width, initial-scale=1">
48+
<style>
49+
body {{
50+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
51+
display: flex;
52+
justify-content: center;
53+
align-items: center;
54+
min-height: 100vh;
55+
margin: 0;
56+
padding: 20px;
57+
background-color: #f5f5f5;
58+
}}
59+
.consent-form {{
60+
background: white;
61+
padding: 40px;
62+
border-radius: 8px;
63+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
64+
width: 100%;
65+
max-width: 400px;
66+
}}
67+
h1 {{
68+
margin: 0 0 20px 0;
69+
font-size: 24px;
70+
font-weight: 600;
71+
}}
72+
p {{
73+
margin-bottom: 20px;
74+
color: #666;
75+
}}
76+
.client-info {{
77+
background: #f8f8f8;
78+
padding: 15px;
79+
border-radius: 4px;
80+
margin-bottom: 20px;
81+
}}
82+
.scopes {{
83+
margin-bottom: 20px;
84+
}}
85+
.scope-item {{
86+
padding: 8px 0;
87+
border-bottom: 1px solid #eee;
88+
}}
89+
.scope-item:last-child {{
90+
border-bottom: none;
91+
}}
92+
.button-group {{
93+
display: flex;
94+
gap: 10px;
95+
}}
96+
button {{
97+
flex: 1;
98+
padding: 10px;
99+
border: none;
100+
border-radius: 4px;
101+
cursor: pointer;
102+
font-size: 16px;
103+
}}
104+
.approve {{
105+
background: #0366d6;
106+
color: white;
107+
}}
108+
.deny {{
109+
background: #f6f8fa;
110+
color: #24292e;
111+
border: 1px solid #d1d5da;
112+
}}
113+
button:hover {{
114+
opacity: 0.9;
115+
}}
116+
</style>
117+
</head>
118+
<body>
119+
<div class="consent-form">
120+
<h1>Authorization Request</h1>
121+
<p>The application <strong>{client_id}</strong> is requesting access to your resources.</p>
122+
123+
<div class="client-info">
124+
<strong>Client ID:</strong> {client_id}<br>
125+
<strong>Redirect URI:</strong> {redirect_uri}
126+
</div>
127+
128+
<div class="scopes">
129+
<strong>Requested Permissions:</strong>
130+
{self._format_scopes(scopes)}
131+
</div>
132+
133+
<form method="POST" action="{target_url}">
134+
<input type="hidden" name="client_id" value="{client_id}">
135+
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
136+
<input type="hidden" name="state" value="{state}">
137+
<input type="hidden" name="scopes" value="{scopes}">
138+
<input type="hidden" name="code_challenge" value="{code_challenge}">
139+
<input type="hidden" name="response_type" value="{response_type}">
140+
141+
<div class="button-group">
142+
<button type="submit" name="action" value="approve" class="approve">Approve</button>
143+
<button type="submit" name="action" value="deny" class="deny">Deny</button>
144+
</div>
145+
</form>
146+
</div>
147+
</body>
148+
</html>
149+
"""
150+
return HTMLResponse(content=html_content)
151+
152+
async def _process_consent(self, request: Request) -> RedirectResponse:
153+
form_data = await request.form()
154+
action = form_data.get("action")
155+
156+
if action == "approve":
157+
# Grant consent and continue with authorization
158+
client_id = form_data.get("client_id")
159+
if client_id:
160+
client = await self.provider.get_client(client_id)
161+
if client:
162+
await self.provider.grant_client_consent(client)
163+
164+
# Redirect back to authorization endpoint with original parameters
165+
auth_params = urlencode({
166+
k: v for k, v in form_data.items()
167+
if k in ["client_id", "redirect_uri", "state", "scopes", "code_challenge", "response_type"]
168+
})
169+
170+
return RedirectResponse(
171+
# TODO: get this passed in
172+
url=f"/authorize?{auth_params}",
173+
status_code=302,
174+
headers={"Cache-Control": "no-store"},
175+
)
176+
else:
177+
# User denied consent
178+
redirect_uri = form_data.get("redirect_uri")
179+
state = form_data.get("state")
180+
181+
error_params = {
182+
"error": "access_denied",
183+
"error_description": "User denied the authorization request"
184+
}
185+
if state:
186+
error_params["state"] = state
187+
188+
if redirect_uri:
189+
return RedirectResponse(
190+
url=f"{redirect_uri}?{urlencode(error_params)}",
191+
status_code=302,
192+
headers={"Cache-Control": "no-store"},
193+
)
194+
else:
195+
return HTMLResponse(
196+
status_code=400,
197+
content=f"Access denied: {error_params['error_description']}"
198+
)
199+
200+
def _format_scopes(self, scopes: str) -> str:
201+
if not scopes:
202+
return "<p>No specific permissions requested</p>"
203+
204+
scope_list = scopes.split()
205+
if not scope_list:
206+
return "<p>No specific permissions requested</p>"
207+
208+
scope_html = ""
209+
for scope in scope_list:
210+
scope_html += f'<div class="scope-item">{scope}</div>'
211+
212+
return scope_html

src/mcp/server/auth/provider.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,27 @@ async def authorize(
174174
"""
175175
...
176176

177+
async def has_client_consent(self, client: OAuthClientInformationFull) -> bool:
178+
"""
179+
Check if a client has already provided consent.
180+
181+
Args:
182+
client: The client to check consent status for.
183+
184+
Returns:
185+
True if the client has provided consent, False otherwise.
186+
"""
187+
...
188+
189+
async def grant_client_consent(self, client: OAuthClientInformationFull) -> None:
190+
"""
191+
Grant consent for a client.
192+
193+
Args:
194+
client: The client to grant consent for.
195+
"""
196+
...
197+
177198
async def load_authorization_code(
178199
self, client: OAuthClientInformationFull, authorization_code: str
179200
) -> AuthorizationCodeT | None:

src/mcp/server/auth/routes.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from starlette.types import ASGIApp
1010

1111
from mcp.server.auth.handlers.authorize import AuthorizationHandler
12+
from mcp.server.auth.handlers.consent import ConsentHandler
1213
from mcp.server.auth.handlers.metadata import MetadataHandler
1314
from mcp.server.auth.handlers.register import RegistrationHandler
1415
from mcp.server.auth.handlers.revoke import RevocationHandler
@@ -49,6 +50,7 @@ def validate_issuer_url(url: AnyHttpUrl):
4950
TOKEN_PATH = "/token"
5051
REGISTRATION_PATH = "/register"
5152
REVOCATION_PATH = "/revoke"
53+
CONSENT_PATH = "/consent"
5254

5355

5456
def cors_middleware(
@@ -113,6 +115,13 @@ def create_auth_routes(
113115
),
114116
methods=["POST", "OPTIONS"],
115117
),
118+
Route(
119+
CONSENT_PATH,
120+
# do not allow CORS for consent endpoint;
121+
# clients should just redirect to this
122+
endpoint=ConsentHandler(provider).handle,
123+
methods=["GET", "POST"],
124+
),
116125
]
117126

118127
if client_registration_options.enabled:

0 commit comments

Comments
 (0)