Skip to content

Commit 429c28f

Browse files
committed
move consent to example
1 parent 011fd07 commit 429c28f

File tree

6 files changed

+248
-308
lines changed

6 files changed

+248
-308
lines changed

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

Lines changed: 248 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
from pydantic_settings import BaseSettings, SettingsConfigDict
1111
from starlette.exceptions import HTTPException
1212
from starlette.requests import Request
13-
from starlette.responses import JSONResponse, RedirectResponse, Response
13+
from starlette.responses import JSONResponse, RedirectResponse, Response, HTMLResponse
14+
from dataclasses import dataclass
15+
1416

1517
from mcp.server.auth.middleware.auth_context import get_access_token
1618
from mcp.server.auth.provider import (
@@ -25,6 +27,7 @@
2527
from mcp.server.fastmcp.server import FastMCP
2628
from mcp.shared._httpx_utils import create_mcp_http_client
2729
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
30+
from urllib.parse import urlencode
2831

2932
logger = logging.getLogger(__name__)
3033

@@ -108,16 +111,25 @@ async def authorize(
108111
"client_id": client.client_id,
109112
}
110113

111-
# Build GitHub authorization URL
112-
auth_url = (
113-
f"{self.settings.github_auth_url}"
114-
f"?client_id={self.settings.github_client_id}"
115-
f"&redirect_uri={self.settings.github_callback_path}"
116-
f"&scope={self.settings.github_scope}"
117-
f"&state={state}"
118-
)
114+
# Return our custom consent endpoint, which will then redirect to Github
115+
116+
# Extract scopes - use default MCP scope if none provided
117+
scopes = params.scopes or [self.settings.mcp_scope]
118+
scopes_string = " ".join(scopes) if isinstance(scopes, list) else str(scopes)
119+
120+
consent_params = {
121+
"client_id": client.client_id,
122+
"redirect_uri": str(params.redirect_uri),
123+
"state": state,
124+
"scopes": scopes_string,
125+
"code_challenge": params.code_challenge or "",
126+
"response_type": "code"
127+
}
128+
129+
consent_url = f"{self.settings.server_url}consent?{urlencode(consent_params)}"
130+
print(f"[DEBUGG] {consent_url} {state}")
119131

120-
return auth_url
132+
return consent_url
121133

122134
async def handle_github_callback(self, code: str, state: str) -> str:
123135
"""Handle GitHub OAuth callback."""
@@ -265,6 +277,224 @@ async def revoke_token(
265277
del self.tokens[token]
266278

267279

280+
@dataclass
281+
class ConsentHandler:
282+
provider: OAuthAuthorizationServerProvider[Any, Any, Any]
283+
settings: ServerSettings
284+
285+
async def handle(self, request: Request) -> Response:
286+
# This handles both showing the consent form (GET) and processing consent (POST)
287+
if request.method == "GET":
288+
# Show consent form
289+
return await self._show_consent_form(request)
290+
elif request.method == "POST":
291+
# Process consent
292+
return await self._process_consent(request)
293+
else:
294+
return HTMLResponse(status_code=405, content="Method not allowed")
295+
296+
async def _show_consent_form(self, request: Request) -> HTMLResponse:
297+
client_id = request.query_params.get("client_id", "")
298+
redirect_uri = request.query_params.get("redirect_uri", "")
299+
state = request.query_params.get("state", "")
300+
scopes = request.query_params.get("scopes", "")
301+
code_challenge = request.query_params.get("code_challenge", "")
302+
response_type = request.query_params.get("response_type", "")
303+
304+
# Get client info to display client_name
305+
client_name = client_id # Default to client_id if we can't get the client
306+
if client_id:
307+
client = await self.provider.get_client(client_id)
308+
if client and hasattr(client, 'client_name'):
309+
client_name = client.client_name
310+
311+
# TODO: get this passed in
312+
target_url = "/consent"
313+
314+
# Create a simple consent form
315+
316+
html_content = f"""
317+
<!DOCTYPE html>
318+
<html>
319+
<head>
320+
<title>Authorization Required</title>
321+
<meta charset="utf-8">
322+
<meta name="viewport" content="width=device-width, initial-scale=1">
323+
<style>
324+
body {{
325+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
326+
display: flex;
327+
justify-content: center;
328+
align-items: center;
329+
min-height: 100vh;
330+
margin: 0;
331+
padding: 20px;
332+
background-color: #f5f5f5;
333+
}}
334+
.consent-form {{
335+
background: white;
336+
padding: 40px;
337+
border-radius: 8px;
338+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
339+
width: 100%;
340+
max-width: 400px;
341+
}}
342+
h1 {{
343+
margin: 0 0 20px 0;
344+
font-size: 24px;
345+
font-weight: 600;
346+
}}
347+
p {{
348+
margin-bottom: 20px;
349+
color: #666;
350+
}}
351+
.client-info {{
352+
background: #f8f8f8;
353+
padding: 15px;
354+
border-radius: 4px;
355+
margin-bottom: 20px;
356+
}}
357+
.scopes {{
358+
margin-bottom: 20px;
359+
}}
360+
.scope-item {{
361+
padding: 8px 0;
362+
border-bottom: 1px solid #eee;
363+
}}
364+
.scope-item:last-child {{
365+
border-bottom: none;
366+
}}
367+
.button-group {{
368+
display: flex;
369+
gap: 10px;
370+
}}
371+
button {{
372+
flex: 1;
373+
padding: 10px;
374+
border: none;
375+
border-radius: 4px;
376+
cursor: pointer;
377+
font-size: 16px;
378+
}}
379+
.approve {{
380+
background: #0366d6;
381+
color: white;
382+
}}
383+
.deny {{
384+
background: #f6f8fa;
385+
color: #24292e;
386+
border: 1px solid #d1d5da;
387+
}}
388+
button:hover {{
389+
opacity: 0.9;
390+
}}
391+
</style>
392+
</head>
393+
<body>
394+
<div class="consent-form">
395+
<h1>Authorization Request</h1>
396+
<p>The application <strong>{client_name}</strong> is requesting access to your resources.</p>
397+
398+
<div class="client-info">
399+
<strong>Application Name:</strong> {client_name}<br>
400+
<strong>Client ID:</strong> {client_id}<br>
401+
<strong>Redirect URI:</strong> {redirect_uri}
402+
</div>
403+
404+
<div class="scopes">
405+
<strong>Requested Permissions:</strong>
406+
{self._format_scopes(scopes)}
407+
</div>
408+
409+
<form method="POST" action="{target_url}">
410+
<input type="hidden" name="client_id" value="{client_id}">
411+
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
412+
<input type="hidden" name="state" value="{state}">
413+
<input type="hidden" name="scopes" value="{scopes}">
414+
<input type="hidden" name="code_challenge" value="{code_challenge}">
415+
<input type="hidden" name="response_type" value="{response_type}">
416+
417+
<div class="button-group">
418+
<button type="submit" name="action" value="approve" class="approve">Approve</button>
419+
<button type="submit" name="action" value="deny" class="deny">Deny</button>
420+
</div>
421+
</form>
422+
</div>
423+
</body>
424+
</html>
425+
"""
426+
return HTMLResponse(content=html_content)
427+
428+
async def _process_consent(self, request: Request) -> RedirectResponse | HTMLResponse:
429+
form_data = await request.form()
430+
action = form_data.get("action")
431+
state = form_data.get("state")
432+
433+
if action == "approve":
434+
# Grant consent and continue with authorization
435+
client_id = form_data.get("client_id")
436+
if client_id:
437+
client = await self.provider.get_client(client_id)
438+
if client:
439+
# TODO: move this out of provider
440+
await self.provider.grant_client_consent(client)
441+
442+
443+
auth_url = (
444+
f"{self.settings.github_auth_url}"
445+
f"?client_id={self.settings.github_client_id}"
446+
f"&redirect_uri={self.settings.github_callback_path}"
447+
f"&scope={self.settings.github_scope}"
448+
f"&state={state}"
449+
)
450+
451+
return RedirectResponse(
452+
# TODO: get this passed in
453+
url=auth_url,
454+
status_code=302,
455+
headers={"Cache-Control": "no-store"},
456+
)
457+
else:
458+
# User denied consent
459+
redirect_uri = form_data.get("redirect_uri")
460+
state = form_data.get("state")
461+
462+
error_params = {
463+
"error": "access_denied",
464+
"error_description": "User denied the authorization request"
465+
}
466+
if state:
467+
error_params["state"] = state
468+
469+
if redirect_uri:
470+
return RedirectResponse(
471+
url=f"{redirect_uri}?{urlencode(error_params)}",
472+
status_code=302,
473+
headers={"Cache-Control": "no-store"},
474+
)
475+
else:
476+
return HTMLResponse(
477+
status_code=400,
478+
content=f"Access denied: {error_params['error_description']}"
479+
)
480+
481+
def _format_scopes(self, scopes: str) -> str:
482+
if not scopes:
483+
return "<p>No specific permissions requested</p>"
484+
485+
scope_list = scopes.split()
486+
if not scope_list:
487+
return "<p>No specific permissions requested</p>"
488+
489+
scope_html = ""
490+
for scope in scope_list:
491+
scope_html += f'<div class="scope-item">{scope}</div>'
492+
493+
return scope_html
494+
495+
496+
497+
268498
def create_simple_mcp_server(settings: ServerSettings) -> FastMCP:
269499
"""Create a simple FastMCP server with GitHub OAuth."""
270500
oauth_provider = SimpleGitHubOAuthProvider(settings)
@@ -275,9 +505,8 @@ def create_simple_mcp_server(settings: ServerSettings) -> FastMCP:
275505
enabled=True,
276506
valid_scopes=[settings.mcp_scope],
277507
default_scopes=[settings.mcp_scope],
278-
# Because we're redirecting to a different AS during our
279-
# main auth flow.
280-
client_consent_required=True
508+
# Turning off consent since we'll handle it via custom endpoint
509+
client_consent_required=False
281510
),
282511
required_scopes=[settings.mcp_scope],
283512
)
@@ -292,6 +521,12 @@ def create_simple_mcp_server(settings: ServerSettings) -> FastMCP:
292521
auth=auth_settings,
293522
)
294523

524+
consent_handler = ConsentHandler(provider=oauth_provider, settings=settings)
525+
526+
@app.custom_route("/consent", methods=["GET", "POST"])
527+
async def example_consent_handler(request: Request) -> Response:
528+
return await consent_handler.handle(request)
529+
295530
@app.custom_route("/github/callback", methods=["GET"])
296531
async def github_callback_handler(request: Request) -> Response:
297532
"""Handle GitHub OAuth callback."""

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

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -219,26 +219,6 @@ async def error_response(
219219
)
220220

221221
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-
242222
# Let the provider pick the next URI to redirect to
243223
return RedirectResponse(
244224
url=await self.provider.authorize(

0 commit comments

Comments
 (0)