1111from pydantic_settings import BaseSettings , SettingsConfigDict
1212from starlette .exceptions import HTTPException
1313from starlette .requests import Request
14- from starlette .responses import JSONResponse , RedirectResponse , Response
14+ from starlette .responses import JSONResponse , RedirectResponse , Response , HTMLResponse
15+ from dataclasses import dataclass
16+
1517
1618from mcp .server .auth .middleware .auth_context import get_access_token
1719from mcp .server .auth .provider import (
2527from mcp .server .auth .settings import AuthSettings , ClientRegistrationOptions
2628from mcp .server .fastmcp .server import FastMCP
2729from mcp .shared .auth import OAuthClientInformationFull , OAuthToken
30+ from urllib .parse import urlencode
2831
2932logger = 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+
268498def 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."""
0 commit comments